771 lines
38 KiB
HTML
771 lines
38 KiB
HTML
<!doctype html>
|
|
<html lang="en" data-bs-theme="dark">
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<title>Attendees — IMFestival SMS Dashboard</title>
|
|
|
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
|
crossorigin="anonymous">
|
|
<link href="assets/css/dashboard.css" rel="stylesheet">
|
|
<link href="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone.css" rel="stylesheet">
|
|
<link href="https://cdn.datatables.net/2.3.8/css/dataTables.dataTables.min.css" rel="stylesheet">
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<!-- ─── Mobile top bar ─── -->
|
|
<div class="topbar">
|
|
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
|
<i class="fa-solid fa-bars"></i>
|
|
</button>
|
|
<span style="font-weight:600;font-size:0.95rem;">Attendees</span>
|
|
</div>
|
|
|
|
<!-- ─── Mobile offcanvas sidebar ─── -->
|
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
<div class="offcanvas-header">
|
|
<div class="brand"
|
|
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
<img src="assets/img/logo.png" alt="Logo" style="width:120px;height:auto;">
|
|
<span>SMS Dashboard</span>
|
|
</div>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"
|
|
aria-label="Close"></button>
|
|
</div>
|
|
<div class="offcanvas-body p-3">
|
|
<nav class="d-flex flex-column gap-1">
|
|
<a href="index.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-house fa-fw"></i>
|
|
Home
|
|
</a>
|
|
<a href="attendees.html" class="nav-link-custom active">
|
|
<i class="fa-solid fa-users fa-fw"></i>
|
|
Attendees
|
|
</a>
|
|
<a href="templates.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
SMS Templates
|
|
</a>
|
|
<a href="schedule.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
Schedule
|
|
</a>
|
|
<a href="delivery-log.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
Delivery Log
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Desktop sidebar ─── -->
|
|
<div class="sidebar">
|
|
<div class="brand">
|
|
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
|
</div>
|
|
|
|
<nav class="d-flex flex-column gap-1 mt-2">
|
|
<a href="index.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-house fa-fw"></i>
|
|
Home
|
|
</a>
|
|
<a href="attendees.html" class="nav-link-custom active">
|
|
<i class="fa-solid fa-users fa-fw"></i>
|
|
Attendees
|
|
</a>
|
|
<a href="templates.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
SMS Templates
|
|
</a>
|
|
<a href="schedule.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
Schedule
|
|
</a>
|
|
<a href="delivery-log.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
Delivery Log
|
|
</a>
|
|
</nav>
|
|
|
|
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
|
<div class="nav-link-custom" style="cursor:default;">
|
|
<i class="fa-solid fa-circle-user fa-fw"></i>
|
|
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Main content ─── -->
|
|
<div class="main">
|
|
<div class="main-inner">
|
|
<div id="sandboxBanner" class="alert alert-warning"
|
|
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
|
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded
|
|
from
|
|
cost calculations.
|
|
</div>
|
|
|
|
<div class="page-header">
|
|
<h1>Attendees</h1>
|
|
<p>Upload a CSV file to import participants.</p>
|
|
</div>
|
|
|
|
<div class="card p-4 mb-4">
|
|
<h5 class="mb-3">Import CSV</h5>
|
|
<div class="mb-3 d-flex flex-wrap gap-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="importMode" id="modeClear" value="clear"
|
|
checked>
|
|
<label class="form-check-label" for="modeClear">
|
|
<strong>Clear & Import</strong>
|
|
<span class="text-muted d-block" style="font-size:0.78rem;">Delete all existing attendees,
|
|
then import</span>
|
|
</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="importMode" id="modeOverwrite"
|
|
value="overwrite">
|
|
<label class="form-check-label" for="modeOverwrite">
|
|
<strong>Overwrite Existing</strong>
|
|
<span class="text-muted d-block" style="font-size:0.78rem;">Update existing attendees, add
|
|
new ones</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="csvDropzone" class="dropzone">
|
|
<div class="dz-message">
|
|
<i class="fa-solid fa-file-csv dz-icon"></i>
|
|
<span>Drop a CSV here or <strong>click to browse</strong></span>
|
|
<span class="text-muted" style="font-size:0.78rem;">Only .csv files accepted · Max 5 MB</span>
|
|
</div>
|
|
</div>
|
|
<div id="confirmUploadBar" class="d-none mt-2 d-flex align-items-center gap-2">
|
|
<span id="confirmUploadFilename" class="text-muted" style="font-size:0.85rem;"></span>
|
|
<button class="btn btn-sm btn-primary" onclick="confirmUpload()">
|
|
<i class="fa-solid fa-upload fa-fw"></i> Confirm Upload
|
|
</button>
|
|
<button class="btn btn-sm btn-secondary" onclick="cancelUpload()">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<div id="importResult" class="mt-2" style="font-size:0.82rem;"></div>
|
|
</div>
|
|
|
|
<!-- ─── Rejected records (shown after import if rows were skipped) ─── -->
|
|
<div id="rejectedSection" class="card p-4 mb-4 d-none">
|
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
|
<h5 class="mb-0">
|
|
<i class="fa-solid fa-circle-exclamation fa-fw me-1" style="color:#facc15;"></i>
|
|
Rejected Records <span id="rejectedCount" class="badge ms-1"
|
|
style="background:rgba(250,204,21,0.15);color:#facc15;font-size:0.75rem;"></span>
|
|
</h5>
|
|
<div class="d-flex gap-2 flex-wrap align-items-center">
|
|
<div class="btn-group btn-group-sm" id="rejectedFilter" role="group"
|
|
aria-label="Filter rejected records">
|
|
<button type="button" class="btn btn-secondary" data-filter="all">All</button>
|
|
<button type="button" class="btn btn-secondary" data-filter="not_attending">Not
|
|
Attending</button>
|
|
<button type="button" class="btn btn-secondary active" data-filter="no_mobile">Missing
|
|
Mobile</button>
|
|
</div>
|
|
<button class="btn btn-sm btn-secondary" onclick="dismissAllRejected()">
|
|
<i class="fa-solid fa-xmark fa-fw"></i> Dismiss All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Mobile (raw)</th>
|
|
<th>Status</th>
|
|
<th>Reason</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rejectedBody">
|
|
<tr>
|
|
<td colspan="6" class="text-muted text-center py-3">No rejected records.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card p-4">
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<h5 class="mb-0">Participants</h5>
|
|
<div class="d-flex align-items-center gap-3">
|
|
<span class="text-muted" id="attendeeCount" style="font-size:0.82rem;">—</span>
|
|
<button class="btn btn-sm btn-danger" id="purgeBtn" onclick="purgeAttendees()">
|
|
<i class="fa-solid fa-trash-can fa-fw"></i> Purge All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-striped mb-0" id="attendeesTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Mobile</th>
|
|
<th>Workshop</th>
|
|
<th>Time</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="attendeeBody">
|
|
<tr>
|
|
<td colspan="6" class="text-muted text-center py-4">Loading…</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
|
crossorigin="anonymous"></script>
|
|
<script src="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone-min.js"></script>
|
|
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
|
|
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
|
|
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
|
|
<script>
|
|
Dropzone.autoDiscover = false;
|
|
|
|
let pendingFile = null;
|
|
let currentDuplicates = [];
|
|
|
|
const dz = new Dropzone("#csvDropzone", {
|
|
url: "/api/attendees.php",
|
|
acceptedFiles: ".csv",
|
|
maxFiles: 1,
|
|
maxFilesize: 5,
|
|
autoProcessQueue: false,
|
|
dictDefaultMessage: "",
|
|
params: () => ({ mode: document.querySelector('input[name="importMode"]:checked').value }),
|
|
});
|
|
|
|
dz.on("addedfile", (file) => {
|
|
document.getElementById('confirmUploadFilename').textContent = file.name;
|
|
document.getElementById('confirmUploadBar').classList.remove('d-none');
|
|
document.getElementById('importResult').innerHTML = '';
|
|
});
|
|
|
|
dz.on("removedfile", () => {
|
|
document.getElementById('confirmUploadBar').classList.add('d-none');
|
|
document.getElementById('confirmUploadFilename').textContent = '';
|
|
});
|
|
|
|
function confirmUpload() {
|
|
dz.processQueue();
|
|
}
|
|
|
|
function cancelUpload() {
|
|
dz.removeAllFiles();
|
|
}
|
|
|
|
dz.on("success", (file, response) => {
|
|
const r = typeof response === 'string' ? JSON.parse(response) : response;
|
|
if (r.status === 'needs_resolution') {
|
|
pendingFile = file;
|
|
currentDuplicates = r.duplicates;
|
|
showDuplicateModal(r.duplicates);
|
|
return;
|
|
}
|
|
dz.removeFile(file);
|
|
const resultEl = document.getElementById('importResult');
|
|
const parts = [];
|
|
if (r.imported) parts.push(`<span class="text-success">${r.imported} added</span>`);
|
|
if (r.updated) parts.push(`<span class="text-info">${r.updated} updated</span>`);
|
|
if (r.skipped) {
|
|
const reasons = [];
|
|
if (r.skip_reasons?.not_attending) reasons.push(`${r.skip_reasons.not_attending} not attending`);
|
|
if (r.skip_reasons?.no_mobile) reasons.push(`${r.skip_reasons.no_mobile} invalid/missing mobile`);
|
|
const detail = reasons.length ? ` (${reasons.join(', ')})` : '';
|
|
parts.push(`<span class="text-muted">${r.skipped} skipped${detail}</span>`);
|
|
}
|
|
resultEl.innerHTML = parts.length ? 'Import complete: ' + parts.join(', ') + '.' : 'Import complete.';
|
|
if (r.rejected && r.rejected.length > 0) {
|
|
showRejectedRecords(r.rejected);
|
|
} else {
|
|
rejectedRows = [];
|
|
renderRejectedTable();
|
|
}
|
|
loadAttendees();
|
|
});
|
|
|
|
dz.on("error", (file, msg) => {
|
|
const errText = typeof msg === 'object' ? (msg.error ?? JSON.stringify(msg)) : msg;
|
|
document.getElementById('importResult').innerHTML = `<span class="text-danger">Import failed: ${esc(errText)}</span>`;
|
|
dz.removeFile(file);
|
|
});
|
|
|
|
let table = null;
|
|
|
|
async function loadAttendees() {
|
|
const tbody = document.getElementById('attendeeBody');
|
|
const countEl = document.getElementById('attendeeCount');
|
|
|
|
// Destroy existing DataTable instance before touching the DOM
|
|
if (table) { table.destroy(); table = null; }
|
|
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">Loading…</td></tr>';
|
|
try {
|
|
const data = await fetch('/api/attendees.php').then(r => r.json());
|
|
const list = data.attendees ?? [];
|
|
countEl.textContent = list.length + ' total';
|
|
if (list.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">No attendees yet. Upload a CSV to get started.</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = list.map(a => `
|
|
<tr>
|
|
<td>${esc(a.first_name + ' ' + a.last_name)}</td>
|
|
<td style="color:var(--text-muted);">${esc(a.email ?? '')}</td>
|
|
<td style="color:var(--text-muted);">${esc(a.mobile_number)}</td>
|
|
<td>${esc(a.workshops ?? '—')}</td>
|
|
<td style="color:var(--text-muted);">${formatWorkshopTimes(a.workshop_times)}</td>
|
|
<td style="white-space:nowrap;">
|
|
<button class="btn btn-sm btn-accent" onclick='openEditModal(${JSON.stringify(a)})'>
|
|
<i class="fa-solid fa-pen fa-fw"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
table = new DataTable('#attendeesTable', { columnDefs: [{ orderable: false, targets: -1 }] });
|
|
} catch {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-4">Could not load attendees.</td></tr>';
|
|
}
|
|
}
|
|
|
|
function esc(str) {
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function formatWorkshopTimes(timesStr) {
|
|
if (!timesStr) return '—';
|
|
return esc(timesStr.split(',').map(t => {
|
|
const d = new Date(t.trim().replace(' ', 'T'));
|
|
return isNaN(d.getTime()) ? t.trim() : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}).join(', '));
|
|
}
|
|
|
|
async function purgeAttendees() {
|
|
if (!confirm('This will permanently delete all attendees and their registrations. Are you sure?')) return;
|
|
const btn = document.getElementById('purgeBtn');
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/attendees.php', { method: 'DELETE' });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
loadAttendees();
|
|
} catch (e) {
|
|
alert('Purge failed: ' + e.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function openApiKeyModal() {
|
|
document.getElementById('apiKeyInput').value = '';
|
|
document.getElementById('apiKeyError').textContent = '';
|
|
const statusEl = document.getElementById('apiKeyStatus');
|
|
statusEl.textContent = 'Checking…';
|
|
try {
|
|
const [keyData, costData] = await Promise.all([
|
|
fetch('/api/settings.php?key=clickatell_api_key').then(r => r.json()),
|
|
fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json()),
|
|
]);
|
|
statusEl.innerHTML = keyData.set
|
|
? `API key set: <code>${esc(keyData.hint)}</code> — enter a new key to replace it.`
|
|
: '<span class="text-warning">No API key configured.</span> Enter one below to enable SMS sending.';
|
|
document.getElementById('costPerSmsInput').value = costData.value ?? '0.04849';
|
|
} catch {
|
|
statusEl.textContent = 'Could not load current settings.';
|
|
}
|
|
}
|
|
|
|
async function saveApiKey() {
|
|
const key = document.getElementById('apiKeyInput').value.trim();
|
|
const cost = document.getElementById('costPerSmsInput').value.trim();
|
|
const errEl = document.getElementById('apiKeyError');
|
|
const btn = document.getElementById('apiKeySaveBtn');
|
|
errEl.textContent = '';
|
|
if (!key && !cost) { errEl.textContent = 'Please enter at least one value.'; return; }
|
|
const costNum = parseFloat(cost);
|
|
if (cost && (isNaN(costNum) || costNum < 0)) { errEl.textContent = 'Cost must be a positive number.'; return; }
|
|
btn.disabled = true;
|
|
try {
|
|
const saves = [];
|
|
if (key) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_api_key', value: key }) }));
|
|
if (cost) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_cost_per_sms', value: cost }) }));
|
|
const results = await Promise.all(saves);
|
|
for (const res of results) { if (!res.ok) throw new Error((await res.json()).error ?? 'Save failed'); }
|
|
bootstrap.Modal.getInstance(document.getElementById('apiKeyModal')).hide();
|
|
} catch (e) {
|
|
errEl.textContent = e.message;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
loadAttendees();
|
|
|
|
// ─── Rejected records ────────────────────────────────────────────────────────
|
|
|
|
let rejectedRows = [];
|
|
let rejectedFilter = 'no_mobile';
|
|
|
|
function showRejectedRecords(rejected) {
|
|
rejectedRows = rejected.map(r => ({ ...r, dismissed: false }));
|
|
rejectedFilter = 'no_mobile';
|
|
document.querySelectorAll('#rejectedFilter button').forEach(b => b.classList.remove('active'));
|
|
document.querySelector('#rejectedFilter button[data-filter="no_mobile"]').classList.add('active');
|
|
renderRejectedTable();
|
|
}
|
|
|
|
function renderRejectedTable() {
|
|
const section = document.getElementById('rejectedSection');
|
|
const tbody = document.getElementById('rejectedBody');
|
|
const countEl = document.getElementById('rejectedCount');
|
|
|
|
const active = rejectedRows.filter(r => !r.dismissed);
|
|
if (active.length === 0) { section.classList.add('d-none'); return; }
|
|
|
|
const visible = rejectedFilter === 'all'
|
|
? active
|
|
: rejectedFilter === 'not_attending'
|
|
? active.filter(r => r.reason === 'not_attending')
|
|
: active.filter(r => r.reason !== 'not_attending');
|
|
section.classList.remove('d-none');
|
|
countEl.textContent = active.length;
|
|
|
|
if (visible.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-3">No records match this filter.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = visible.map(r => {
|
|
const idx = rejectedRows.indexOf(r);
|
|
const reasonBadges = {
|
|
not_attending: '<span class="badge" style="background:rgba(250,204,21,0.15);color:#facc15;">Not Attending</span>',
|
|
no_mobile: '<span class="badge" style="background:rgba(248,113,113,0.15);color:#f87171;">No Mobile</span>',
|
|
foreign_country_code: '<span class="badge" style="background:rgba(251,146,60,0.15);color:#fb923c;">Foreign Number</span>',
|
|
invalid_number: '<span class="badge" style="background:rgba(248,113,113,0.15);color:#f87171;">Invalid Number</span>',
|
|
};
|
|
const reasonBadge = reasonBadges[r.reason] ?? reasonBadges.invalid_number;
|
|
const addBtn = r.reason !== 'not_attending'
|
|
? `<button class="btn btn-sm btn-accent me-1" onclick="addRejectedManually(${idx})"><i class="fa-solid fa-user-plus fa-fw"></i> Add</button>`
|
|
: '';
|
|
return `
|
|
<tr>
|
|
<td>${esc(r.first_name + ' ' + r.last_name)}</td>
|
|
<td style="color:var(--text-muted);">${esc(r.email ?? '')}</td>
|
|
<td style="color:var(--text-muted);">${esc(r.raw_mobile || '—')}</td>
|
|
<td style="color:var(--text-muted);">${esc(r.status || '—')}</td>
|
|
<td>${reasonBadge}</td>
|
|
<td style="white-space:nowrap;">
|
|
${addBtn}
|
|
<button class="btn btn-sm btn-secondary" onclick="dismissRejected(${idx})"><i class="fa-solid fa-xmark fa-fw"></i></button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function dismissRejected(idx) {
|
|
rejectedRows[idx].dismissed = true;
|
|
renderRejectedTable();
|
|
}
|
|
|
|
function dismissAllRejected() {
|
|
rejectedRows.forEach(r => { r.dismissed = true; });
|
|
renderRejectedTable();
|
|
}
|
|
|
|
function addRejectedManually(idx) {
|
|
const r = rejectedRows[idx];
|
|
openEditModal({
|
|
id: 0,
|
|
first_name: r.first_name,
|
|
last_name: r.last_name,
|
|
email: r.email ?? '',
|
|
mobile_number: r.raw_mobile ?? '',
|
|
workshop_session_id: null,
|
|
_rejectedIdx: idx,
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('#rejectedFilter button').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('#rejectedFilter button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
rejectedFilter = btn.dataset.filter;
|
|
renderRejectedTable();
|
|
});
|
|
});
|
|
|
|
let editingAttendee = null;
|
|
|
|
function openEditModal(a) {
|
|
editingAttendee = a;
|
|
document.getElementById('editFirstName').value = a.first_name ?? '';
|
|
document.getElementById('editLastName').value = a.last_name ?? '';
|
|
document.getElementById('editEmail').value = a.email ?? '';
|
|
document.getElementById('editMobile').value = a.mobile_number ?? '';
|
|
document.getElementById('editError').textContent = '';
|
|
|
|
const sel = document.getElementById('editWorkshop');
|
|
sel.innerHTML = '<option value="">— No workshop —</option>';
|
|
fetch('/api/workshops.php').then(r => r.json()).then(data => {
|
|
const workshops = data.workshops ?? [];
|
|
// Group sessions by workshop name (case-insensitive) so capitalisation
|
|
// variants (e.g. "Hot Sauce tasting" vs "Hot Sauce Tasting") are merged.
|
|
const groups = {};
|
|
workshops.forEach(ws => {
|
|
const key = ws.workshop_name.toLowerCase();
|
|
if (!groups[key]) groups[key] = { label: ws.workshop_name, sessions: [] };
|
|
groups[key].sessions.push(ws);
|
|
});
|
|
Object.values(groups).forEach(({ label, sessions }) => {
|
|
const fmtTime = ws => ws.workshop_time
|
|
? new Date(ws.workshop_time.replace(' ', 'T')).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
: null;
|
|
if (sessions.length === 1) {
|
|
const ws = sessions[0];
|
|
const opt = document.createElement('option');
|
|
opt.value = ws.id;
|
|
const time = fmtTime(ws);
|
|
opt.textContent = label + (time ? ' (' + time + ')' : '');
|
|
if (String(ws.id) === String(a.workshop_session_id)) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
} else {
|
|
const grp = document.createElement('optgroup');
|
|
grp.label = label;
|
|
sessions.forEach(ws => {
|
|
const opt = document.createElement('option');
|
|
opt.value = ws.id;
|
|
opt.textContent = fmtTime(ws) ?? '(no time)';
|
|
if (String(ws.id) === String(a.workshop_session_id)) opt.selected = true;
|
|
grp.appendChild(opt);
|
|
});
|
|
sel.appendChild(grp);
|
|
}
|
|
});
|
|
});
|
|
|
|
new bootstrap.Modal(document.getElementById('editModal')).show();
|
|
}
|
|
|
|
async function saveEdit() {
|
|
const errEl = document.getElementById('editError');
|
|
const btn = document.getElementById('editSaveBtn');
|
|
errEl.textContent = '';
|
|
btn.disabled = true;
|
|
try {
|
|
const wsVal = document.getElementById('editWorkshop').value;
|
|
const res = await fetch('/api/attendees.php', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: editingAttendee.id,
|
|
first_name: document.getElementById('editFirstName').value.trim(),
|
|
last_name: document.getElementById('editLastName').value.trim(),
|
|
email: document.getElementById('editEmail').value.trim(),
|
|
mobile_number: document.getElementById('editMobile').value.trim(),
|
|
workshop_session_id: wsVal ? parseInt(wsVal, 10) : 0,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error ?? 'Save failed');
|
|
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
|
// If this was a manual-add from the rejected table, dismiss that row
|
|
if (editingAttendee._rejectedIdx !== undefined) {
|
|
rejectedRows[editingAttendee._rejectedIdx].dismissed = true;
|
|
renderRejectedTable();
|
|
}
|
|
loadAttendees();
|
|
} catch (e) {
|
|
errEl.textContent = e.message;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function showDuplicateModal(duplicates) {
|
|
const container = document.getElementById('dupGroups');
|
|
container.innerHTML = duplicates.map((dup, gi) => {
|
|
const rowHtml = dup.rows.map((row, ri) => `
|
|
<div class="form-check mb-1">
|
|
<input class="form-check-input" type="radio" name="dup_${gi}" id="dup_${gi}_${ri}" value="${ri}"${ri === 0 ? ' checked' : ''}>
|
|
<label class="form-check-label" for="dup_${gi}_${ri}">
|
|
<strong>${esc(row.first_name)} ${esc(row.last_name)}</strong>
|
|
${row.email ? `<span class="text-muted ms-1">· ${esc(row.email)}</span>` : ''}
|
|
${row.workshop_name ? `<span class="badge bg-secondary ms-1">${esc(row.workshop_name)}</span>` : ''}
|
|
</label>
|
|
</div>`).join('');
|
|
return `
|
|
<div class="mb-3 p-3 rounded" style="background:var(--bg-card);border:1px solid var(--border);">
|
|
<div class="mb-2 fw-semibold" style="font-size:0.85rem;color:var(--text-muted);">
|
|
<i class="fa-solid fa-phone fa-fw me-1 text-warning"></i>${esc(dup.mobile)}
|
|
</div>
|
|
${rowHtml}
|
|
</div>`;
|
|
}).join('');
|
|
new bootstrap.Modal(document.getElementById('dupModal')).show();
|
|
}
|
|
|
|
function cancelDuplicates() {
|
|
bootstrap.Modal.getInstance(document.getElementById('dupModal')).hide();
|
|
if (pendingFile) { dz.removeFile(pendingFile); pendingFile = null; }
|
|
currentDuplicates = [];
|
|
}
|
|
|
|
async function confirmDuplicates() {
|
|
const resolutions = {};
|
|
currentDuplicates.forEach((dup, gi) => {
|
|
const sel = document.querySelector(`input[name="dup_${gi}"]:checked`);
|
|
if (sel) resolutions[dup.mobile] = parseInt(sel.value, 10);
|
|
});
|
|
const btn = document.getElementById('dupConfirmBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin fa-fw"></i> Importing…';
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', pendingFile, pendingFile.name);
|
|
fd.append('mode', document.querySelector('input[name="importMode"]:checked').value);
|
|
fd.append('resolutions', JSON.stringify(resolutions));
|
|
const res = await fetch('/api/attendees.php', { method: 'POST', body: fd });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error ?? 'Import failed');
|
|
bootstrap.Modal.getInstance(document.getElementById('dupModal')).hide();
|
|
dz.removeFile(pendingFile);
|
|
pendingFile = null;
|
|
currentDuplicates = [];
|
|
const resultEl = document.getElementById('importResult');
|
|
const parts = [];
|
|
if (data.imported) parts.push(`<span class="text-success">${data.imported} added</span>`);
|
|
if (data.updated) parts.push(`<span class="text-info">${data.updated} updated</span>`);
|
|
if (data.skipped) {
|
|
const reasons = [];
|
|
if (data.skip_reasons?.not_attending) reasons.push(`${data.skip_reasons.not_attending} not attending`);
|
|
if (data.skip_reasons?.no_mobile) reasons.push(`${data.skip_reasons.no_mobile} invalid/missing mobile`);
|
|
const detail = reasons.length ? ` (${reasons.join(', ')})` : '';
|
|
parts.push(`<span class="text-muted">${data.skipped} skipped${detail}</span>`);
|
|
}
|
|
resultEl.innerHTML = parts.length ? 'Import complete: ' + parts.join(', ') + '.' : 'Import complete.';
|
|
if (data.rejected && data.rejected.length > 0) {
|
|
showRejectedRecords(data.rejected);
|
|
} else {
|
|
rejectedRows = [];
|
|
renderRejectedTable();
|
|
}
|
|
loadAttendees();
|
|
} catch (e) {
|
|
document.getElementById('importResult').innerHTML = `<span class="text-danger">Import failed: ${esc(e.message)}</span>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fa-solid fa-check fa-fw"></i> Confirm Import';
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<!-- ─── Edit attendee modal ─── -->
|
|
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="editModalLabel">
|
|
<i class="fa-solid fa-pen fa-fw me-2"></i>Edit Attendee
|
|
</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
|
|
aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row g-3">
|
|
<div class="col-6">
|
|
<label class="form-label">First Name</label>
|
|
<input type="text" class="form-control" id="editFirstName">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">Last Name</label>
|
|
<input type="text" class="form-control" id="editLastName">
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Email</label>
|
|
<input type="email" class="form-control" id="editEmail">
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Mobile Number</label>
|
|
<input type="text" class="form-control" id="editMobile">
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Workshop</label>
|
|
<select class="form-select" id="editWorkshop">
|
|
<option value="">— No workshop —</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div id="editError" class="text-danger mt-3" style="font-size:0.82rem;"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="editSaveBtn" onclick="saveEdit()">
|
|
<i class="fa-solid fa-floppy-disk fa-fw"></i> Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Duplicate resolution modal ─── -->
|
|
<div class="modal fade" id="dupModal" tabindex="-1" data-bs-backdrop="static" aria-labelledby="dupModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="dupModalLabel">
|
|
<i class="fa-solid fa-triangle-exclamation fa-fw me-2 text-warning"></i>Duplicate Mobile Numbers
|
|
</h5>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-muted mb-3" style="font-size:0.85rem;">The following mobile numbers appear more than once in your CSV. Choose which entry to import for each:</p>
|
|
<div id="dupGroups"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" onclick="cancelDuplicates()">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="dupConfirmBtn" onclick="confirmDuplicates()">
|
|
<i class="fa-solid fa-check fa-fw"></i> Confirm Import
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
async function checkAuth() {
|
|
try {
|
|
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
|
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
|
|
const d = await r.json();
|
|
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
|
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
|
|
const name = d.name || d.username;
|
|
if (name) {
|
|
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
|
}
|
|
} catch { }
|
|
}
|
|
checkAuth();
|
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|