feat: detect duplicates in csv
This commit is contained in:
+17
-2
@@ -90,7 +90,7 @@
|
||||
<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 style="font-size:0.82rem;">Admin</span>
|
||||
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,7 +245,21 @@
|
||||
|
||||
async function loadAdmin() {
|
||||
try {
|
||||
const data = await fetch('/api/admin.php').then(r => r.json());
|
||||
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
||||
if (r.type === 'opaqueredirect') { window.location.reload(); return; }
|
||||
if (!r.ok) { window.location.replace('index.html'); return; }
|
||||
const data = await r.json();
|
||||
|
||||
if (!data.is_admin) {
|
||||
window.location.replace('index.html');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.remove('d-none'));
|
||||
const name = data.name || data.username;
|
||||
if (name) {
|
||||
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
||||
}
|
||||
|
||||
document.getElementById('sandboxToggle').checked = data.sandbox_mode;
|
||||
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
|
||||
@@ -359,6 +373,7 @@
|
||||
// ─── Init ────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadAdmin();
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') loadAdmin(); });
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -32,8 +32,17 @@ function handle_get(): never
|
||||
FROM delivery_log"
|
||||
)->fetch();
|
||||
|
||||
$groups_raw = $_SERVER['HTTP_X_AUTHENTIK_GROUPS'] ?? '';
|
||||
$groups = ($groups_raw !== '') ? explode('|', $groups_raw) : [];
|
||||
$is_admin = in_array('imf_sms_admin', $groups, true);
|
||||
$username = $_SERVER['HTTP_X_AUTHENTIK_USERNAME'] ?? '';
|
||||
$name = $_SERVER['HTTP_X_AUTHENTIK_NAME'] ?? $username;
|
||||
|
||||
json_out([
|
||||
'sandbox_mode' => $sandbox_mode,
|
||||
'is_admin' => $is_admin,
|
||||
'username' => $username,
|
||||
'name' => $name,
|
||||
'delivery_log_total' => (int) ($counts['total'] ?? 0),
|
||||
'delivery_log_sandbox' => (int) ($counts['sandbox_count'] ?? 0),
|
||||
'delivery_log_real_sent' => (int) ($counts['real_sent'] ?? 0),
|
||||
@@ -44,10 +53,23 @@ function handle_get(): never
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Admin guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
function require_admin(): void
|
||||
{
|
||||
$groups_raw = $_SERVER['HTTP_X_AUTHENTIK_GROUPS'] ?? '';
|
||||
$groups = ($groups_raw !== '') ? explode('|', $groups_raw) : [];
|
||||
if (!in_array('imf_sms_admin', $groups, true)) {
|
||||
error_out('Forbidden — requires imf_sms_admin group', 403);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /api/admin.php ──────────────────────────────────────────────────────
|
||||
|
||||
function handle_post(): never
|
||||
{
|
||||
require_admin();
|
||||
|
||||
$body = json_body();
|
||||
$action = (string) ($body['action'] ?? '');
|
||||
|
||||
|
||||
+148
-90
@@ -121,6 +121,129 @@ function handle_post(): never
|
||||
$mode = 'clear';
|
||||
}
|
||||
|
||||
// Resolutions map: mobile_number => chosen group index (0-based)
|
||||
// Sent on the second submission after the user resolves duplicates in the UI.
|
||||
$resolutions = [];
|
||||
if (!empty($_POST['resolutions'])) {
|
||||
$dec = json_decode((string) $_POST['resolutions'], true);
|
||||
if (is_array($dec)) {
|
||||
$resolutions = $dec;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 1: Parse all valid rows into memory ────────────────────────────
|
||||
|
||||
$validRows = [];
|
||||
$skipped = 0;
|
||||
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
|
||||
$rejected = [];
|
||||
|
||||
while (($row = fgetcsv($handle, 0, $delim)) !== false) {
|
||||
$row = array_map(fn($v) => trim((string) $v, " \t\n\r\0\x0B\""), $row);
|
||||
|
||||
$firstName = trim((string) ($row[$col['first_name']] ?? ''));
|
||||
$lastName = trim((string) ($row[$col['last_name']] ?? ''));
|
||||
$email = trim((string) ($row[$col['email']] ?? ''));
|
||||
$rawMobile = trim((string) ($row[$col['mobile_number']] ?? ''), " \t\n\r\0\x0B\"'");
|
||||
$rawStatus = $col['status'] !== null ? trim((string) ($row[$col['status']] ?? '')) : '';
|
||||
|
||||
if ($col['status'] !== null) {
|
||||
$status = strtolower($rawStatus);
|
||||
if ($status !== '' && $status !== 'attending') {
|
||||
$skipped++;
|
||||
$skipReasons['not_attending']++;
|
||||
$rejected[] = [
|
||||
'first_name' => $firstName, 'last_name' => $lastName,
|
||||
'email' => $email, 'raw_mobile' => $rawMobile,
|
||||
'status' => $rawStatus, 'reason' => 'not_attending',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$mobile = sanitise_mobile($rawMobile);
|
||||
if ($mobile === '') {
|
||||
$skipped++;
|
||||
$skipReasons['no_mobile']++;
|
||||
$rejected[] = [
|
||||
'first_name' => $firstName, 'last_name' => $lastName,
|
||||
'email' => $email, 'raw_mobile' => $rawMobile,
|
||||
'status' => $rawStatus, 'reason' => classify_mobile_rejection($rawMobile),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse workshop columns up-front (needed for duplicate display and import)
|
||||
$wsName = $hasWorkshops ? trim((string) ($row[$col['workshop_name']] ?? '')) : '';
|
||||
$wsTime = ($hasWorkshops && $col['workshop_time'] !== null)
|
||||
? trim((string) ($row[$col['workshop_time']] ?? ''))
|
||||
: '';
|
||||
$dt = null;
|
||||
if ($hasWorkshops && $wsName !== '') {
|
||||
if ($wsTime === '') {
|
||||
$wsTime = extract_time_from_string($wsName) ?? '';
|
||||
$wsName = trim_time_from_string($wsName);
|
||||
}
|
||||
if ($wsTime !== '') {
|
||||
$parsed = strtotime($wsTime);
|
||||
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
|
||||
}
|
||||
}
|
||||
|
||||
$validRows[] = [
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'email' => $email,
|
||||
'mobile_number' => $mobile,
|
||||
'workshop_name' => $wsName,
|
||||
'workshop_time' => $dt,
|
||||
];
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
// ─── Phase 2: Detect intra-CSV duplicate mobile numbers ───────────────────
|
||||
|
||||
$byMobile = [];
|
||||
foreach ($validRows as $i => $vr) {
|
||||
$byMobile[$vr['mobile_number']][] = $i;
|
||||
}
|
||||
|
||||
$duplicateGroups = [];
|
||||
foreach ($byMobile as $mobile => $indices) {
|
||||
if (count($indices) > 1) {
|
||||
$duplicateGroups[] = [
|
||||
'mobile' => $mobile,
|
||||
'rows' => array_values(array_map(fn($i) => $validRows[$i], $indices)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// If duplicates exist and the user hasn't resolved them yet, pause and ask.
|
||||
if (!empty($duplicateGroups) && empty($resolutions)) {
|
||||
json_out(['status' => 'needs_resolution', 'duplicates' => $duplicateGroups]);
|
||||
}
|
||||
|
||||
// Apply resolutions — keep only the chosen row for each duplicate mobile.
|
||||
if (!empty($resolutions)) {
|
||||
$indicesToRemove = [];
|
||||
foreach ($byMobile as $mobile => $indices) {
|
||||
if (!isset($resolutions[$mobile]) || count($indices) < 2) {
|
||||
continue;
|
||||
}
|
||||
$chosenGroupPos = (int) $resolutions[$mobile];
|
||||
foreach ($indices as $groupPos => $rowIdx) {
|
||||
if ($groupPos !== $chosenGroupPos) {
|
||||
$indicesToRemove[$rowIdx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$validRows = array_values(
|
||||
array_filter($validRows, fn($_, $i) => !isset($indicesToRemove[$i]), ARRAY_FILTER_USE_BOTH)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Phase 3: Import ──────────────────────────────────────────────────────
|
||||
|
||||
$db = db();
|
||||
$db->beginTransaction();
|
||||
|
||||
@@ -132,7 +255,7 @@ function handle_post(): never
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-populate workshop cache from existing sessions (for overwrite/append)
|
||||
// Pre-populate workshop cache from existing sessions (for overwrite mode)
|
||||
$workshopCache = [];
|
||||
if ($hasWorkshops && $mode !== 'clear') {
|
||||
foreach ($db->query('SELECT id, workshop_name, workshop_time FROM workshop_sessions')->fetchAll() as $ws) {
|
||||
@@ -140,76 +263,39 @@ function handle_post(): never
|
||||
}
|
||||
}
|
||||
|
||||
$workshopStmt = $db->prepare(
|
||||
$workshopStmt = $db->prepare(
|
||||
'INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)'
|
||||
);
|
||||
$regStmt = $db->prepare(
|
||||
$regStmt = $db->prepare(
|
||||
'INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:attendee_id, :ws_id)'
|
||||
);
|
||||
$insertStmt = $db->prepare(
|
||||
$insertStmt = $db->prepare(
|
||||
'INSERT INTO attendees (first_name, last_name, email, mobile_number)
|
||||
VALUES (:first_name, :last_name, :email, :mobile_number)'
|
||||
VALUES (:first_name, :last_name, :email, :mobile_number)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
first_name = VALUES(first_name),
|
||||
last_name = VALUES(last_name),
|
||||
email = VALUES(email),
|
||||
id = LAST_INSERT_ID(id)'
|
||||
);
|
||||
// Match by email — most stable identifier
|
||||
$findByEmailStmt = $db->prepare(
|
||||
"SELECT id FROM attendees WHERE email = :email AND email != ''"
|
||||
);
|
||||
// Update all fields including mobile (e.g. number may have changed, matched by email)
|
||||
$updateStmt = $db->prepare(
|
||||
$updateStmt = $db->prepare(
|
||||
'UPDATE attendees SET first_name=:first_name, last_name=:last_name, email=:email, mobile_number=:mobile_number WHERE id=:id'
|
||||
);
|
||||
$deleteRegsStmt = $db->prepare(
|
||||
$deleteRegsStmt = $db->prepare(
|
||||
'DELETE FROM registrations WHERE attendee_id = :attendee_id'
|
||||
);
|
||||
|
||||
$imported = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
|
||||
$rejected = [];
|
||||
|
||||
while (($row = fgetcsv($handle, 0, $delim)) !== false) {
|
||||
// Strip leading/trailing double-quotes that some spreadsheet exports add around field values
|
||||
$row = array_map(fn($v) => trim((string) $v, " \t\n\r\0\x0B\""), $row);
|
||||
|
||||
$firstName = trim((string) ($row[$col['first_name']] ?? ''));
|
||||
$lastName = trim((string) ($row[$col['last_name']] ?? ''));
|
||||
$email = trim((string) ($row[$col['email']] ?? ''));
|
||||
$rawMobile = trim((string) ($row[$col['mobile_number']] ?? ''), " \t\n\r\0\x0B\"'");
|
||||
$rawStatus = $col['status'] !== null ? trim((string) ($row[$col['status']] ?? '')) : '';
|
||||
|
||||
// Skip non-attending rows when a status column is present
|
||||
if ($col['status'] !== null) {
|
||||
$status = strtolower($rawStatus);
|
||||
if ($status !== '' && $status !== 'attending') {
|
||||
$skipped++;
|
||||
$skipReasons['not_attending']++;
|
||||
$rejected[] = [
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'email' => $email,
|
||||
'raw_mobile' => $rawMobile,
|
||||
'status' => $rawStatus,
|
||||
'reason' => 'not_attending',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$mobile = sanitise_mobile($rawMobile);
|
||||
if ($mobile === '') {
|
||||
$skipped++;
|
||||
$skipReasons['no_mobile']++;
|
||||
$rejected[] = [
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'email' => $email,
|
||||
'raw_mobile' => $rawMobile,
|
||||
'status' => $rawStatus,
|
||||
'reason' => classify_mobile_rejection($rawMobile),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
foreach ($validRows as $vr) {
|
||||
$firstName = $vr['first_name'];
|
||||
$lastName = $vr['last_name'];
|
||||
$email = $vr['email'];
|
||||
$mobile = $vr['mobile_number'];
|
||||
|
||||
if ($mode === 'clear') {
|
||||
$insertStmt->execute([
|
||||
@@ -219,9 +305,9 @@ function handle_post(): never
|
||||
':mobile_number' => $mobile,
|
||||
]);
|
||||
$attendeeId = (int) $db->lastInsertId();
|
||||
$imported++;
|
||||
// rowCount: 1 = new insert, 2 = ON DUPLICATE KEY UPDATE fired
|
||||
if ($insertStmt->rowCount() >= 2) { $updated++; } else { $imported++; }
|
||||
} else {
|
||||
// Match by email only
|
||||
$matches = [];
|
||||
if ($email !== '') {
|
||||
$findByEmailStmt->execute([':email' => $email]);
|
||||
@@ -229,7 +315,6 @@ function handle_post(): never
|
||||
}
|
||||
|
||||
if (count($matches) === 0) {
|
||||
// Not found — insert
|
||||
$insertStmt->execute([
|
||||
':first_name' => $firstName,
|
||||
':last_name' => $lastName,
|
||||
@@ -239,7 +324,6 @@ function handle_post(): never
|
||||
$attendeeId = (int) $db->lastInsertId();
|
||||
$imported++;
|
||||
} else {
|
||||
// overwrite: update all fields (incl. mobile), collapse any duplicates
|
||||
$attendeeId = (int) $matches[0]['id'];
|
||||
$updateStmt->execute([
|
||||
':first_name' => $firstName,
|
||||
@@ -259,42 +343,16 @@ function handle_post(): never
|
||||
}
|
||||
}
|
||||
|
||||
// Assign to a workshop session (runs for all modes)
|
||||
if ($hasWorkshops) {
|
||||
$wsName = trim((string) ($row[$col['workshop_name']] ?? ''));
|
||||
|
||||
if ($wsName !== '') {
|
||||
// Try separate time column first; otherwise extract time from the name
|
||||
$wsTime = $col['workshop_time'] !== null
|
||||
? trim((string) ($row[$col['workshop_time']] ?? ''))
|
||||
: '';
|
||||
|
||||
if ($wsTime === '') {
|
||||
$wsTime = extract_time_from_string($wsName) ?? '';
|
||||
// Strip the extracted time token from the workshop name
|
||||
$wsName = trim_time_from_string($wsName);
|
||||
}
|
||||
|
||||
$dt = null;
|
||||
if ($wsTime !== '') {
|
||||
$parsed = strtotime($wsTime);
|
||||
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
|
||||
}
|
||||
|
||||
$cacheKey = $wsName . '|' . ($dt ?? '');
|
||||
if (!isset($workshopCache[$cacheKey])) {
|
||||
$workshopStmt->execute([':name' => $wsName, ':time' => $dt]);
|
||||
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
|
||||
}
|
||||
$regStmt->execute([
|
||||
':attendee_id' => $attendeeId,
|
||||
':ws_id' => $workshopCache[$cacheKey],
|
||||
]);
|
||||
if ($hasWorkshops && $vr['workshop_name'] !== '') {
|
||||
$cacheKey = $vr['workshop_name'] . '|' . ($vr['workshop_time'] ?? '');
|
||||
if (!isset($workshopCache[$cacheKey])) {
|
||||
$workshopStmt->execute([':name' => $vr['workshop_name'], ':time' => $vr['workshop_time']]);
|
||||
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
|
||||
}
|
||||
$regStmt->execute([':attendee_id' => $attendeeId, ':ws_id' => $workshopCache[$cacheKey]]);
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
$db->commit();
|
||||
|
||||
json_out(['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'skip_reasons' => $skipReasons, 'workshops_imported' => $hasWorkshops, 'rejected' => $rejected]);
|
||||
|
||||
@@ -46,6 +46,13 @@ function handle_get(): never
|
||||
|
||||
function handle_post(): never
|
||||
{
|
||||
// Only admins may write settings
|
||||
$groups_raw = $_SERVER['HTTP_X_AUTHENTIK_GROUPS'] ?? '';
|
||||
$groups = ($groups_raw !== '') ? explode('|', $groups_raw) : [];
|
||||
if (!in_array('imf_sms_admin', $groups, true)) {
|
||||
error_out('Forbidden — requires imf_sms_admin group', 403);
|
||||
}
|
||||
|
||||
$body = json_decode((string) file_get_contents('php://input'), true);
|
||||
|
||||
$key = trim((string) ($body['key'] ?? ''));
|
||||
|
||||
+123
-5
@@ -93,7 +93,7 @@
|
||||
<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 style="font-size:0.82rem;">Admin</span>
|
||||
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,6 +240,9 @@
|
||||
<script>
|
||||
Dropzone.autoDiscover = false;
|
||||
|
||||
let pendingFile = null;
|
||||
let currentDuplicates = [];
|
||||
|
||||
const dz = new Dropzone("#csvDropzone", {
|
||||
url: "/api/attendees.php",
|
||||
acceptedFiles: ".csv",
|
||||
@@ -270,8 +273,14 @@
|
||||
}
|
||||
|
||||
dz.on("success", (file, response) => {
|
||||
dz.removeFile(file);
|
||||
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>`);
|
||||
@@ -589,6 +598,83 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 ─── -->
|
||||
@@ -639,13 +725,45 @@
|
||||
</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 () {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
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>
|
||||
|
||||
|
||||
+13
-4
@@ -90,7 +90,7 @@
|
||||
<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 style="font-size:0.82rem;">Admin</span>
|
||||
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,12 +245,21 @@
|
||||
loadLog();
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
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>
|
||||
|
||||
|
||||
+20
-5
@@ -89,7 +89,7 @@
|
||||
<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 style="font-size:0.82rem;">Admin</span>
|
||||
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,10 +197,12 @@
|
||||
</a>
|
||||
</div>
|
||||
<!-- Start Admin Settings -->
|
||||
<div id="adminSection" class="d-none col-12">
|
||||
<hr class="mt-4 mb-3">
|
||||
<div class="page-header mt-0 mb-0">
|
||||
<div class="page-header mt-0 mb-4">
|
||||
<p><em>You're seeing these options because you're an admin. Proceed with caution!</em></p>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-sm-6">
|
||||
<a href="admin.html" class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
|
||||
<i class="fa-solid fa-user-shield fa-lg" style="color:var(--accent);"></i>
|
||||
@@ -223,6 +225,8 @@
|
||||
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div><!-- /.row (admin cards) -->
|
||||
</div><!-- /#adminSection -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,12 +336,23 @@
|
||||
loadStats();
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
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 adminSection = document.getElementById('adminSection');
|
||||
if (adminSection) adminSection.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>
|
||||
|
||||
|
||||
+13
-4
@@ -104,7 +104,7 @@
|
||||
<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 style="font-size:0.82rem;">Admin</span>
|
||||
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -501,12 +501,21 @@
|
||||
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
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>
|
||||
|
||||
|
||||
+13
-4
@@ -90,7 +90,7 @@
|
||||
<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 style="font-size:0.82rem;">Admin</span>
|
||||
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,12 +369,21 @@
|
||||
loadTemplatePicker();
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
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>
|
||||
|
||||
|
||||
+13
-4
@@ -90,7 +90,7 @@
|
||||
<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 style="font-size:0.82rem;">Admin</span>
|
||||
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,12 +296,21 @@
|
||||
loadTemplates();
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user