feat: detect duplicates in csv
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
FROM php:8.3-apache
|
FROM php:8.3-apache
|
||||||
RUN docker-php-ext-install pdo pdo_mysql
|
RUN docker-php-ext-install pdo pdo_mysql
|
||||||
|
COPY ./web /var/www/html/
|
||||||
|
|||||||
+17
-2
@@ -90,7 +90,7 @@
|
|||||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||||
<div class="nav-link-custom" style="cursor:default;">
|
<div class="nav-link-custom" style="cursor:default;">
|
||||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +245,21 @@
|
|||||||
|
|
||||||
async function loadAdmin() {
|
async function loadAdmin() {
|
||||||
try {
|
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('sandboxToggle').checked = data.sandbox_mode;
|
||||||
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
|
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
|
||||||
@@ -359,6 +373,7 @@
|
|||||||
// ─── Init ────────────────────────────────────────────────────────────────────
|
// ─── Init ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
loadAdmin();
|
loadAdmin();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') loadAdmin(); });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,17 @@ function handle_get(): never
|
|||||||
FROM delivery_log"
|
FROM delivery_log"
|
||||||
)->fetch();
|
)->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([
|
json_out([
|
||||||
'sandbox_mode' => $sandbox_mode,
|
'sandbox_mode' => $sandbox_mode,
|
||||||
|
'is_admin' => $is_admin,
|
||||||
|
'username' => $username,
|
||||||
|
'name' => $name,
|
||||||
'delivery_log_total' => (int) ($counts['total'] ?? 0),
|
'delivery_log_total' => (int) ($counts['total'] ?? 0),
|
||||||
'delivery_log_sandbox' => (int) ($counts['sandbox_count'] ?? 0),
|
'delivery_log_sandbox' => (int) ($counts['sandbox_count'] ?? 0),
|
||||||
'delivery_log_real_sent' => (int) ($counts['real_sent'] ?? 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 ──────────────────────────────────────────────────────
|
// ─── POST /api/admin.php ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handle_post(): never
|
function handle_post(): never
|
||||||
{
|
{
|
||||||
|
require_admin();
|
||||||
|
|
||||||
$body = json_body();
|
$body = json_body();
|
||||||
$action = (string) ($body['action'] ?? '');
|
$action = (string) ($body['action'] ?? '');
|
||||||
|
|
||||||
|
|||||||
+141
-83
@@ -121,6 +121,129 @@ function handle_post(): never
|
|||||||
$mode = 'clear';
|
$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 = db();
|
||||||
$db->beginTransaction();
|
$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 = [];
|
$workshopCache = [];
|
||||||
if ($hasWorkshops && $mode !== 'clear') {
|
if ($hasWorkshops && $mode !== 'clear') {
|
||||||
foreach ($db->query('SELECT id, workshop_name, workshop_time FROM workshop_sessions')->fetchAll() as $ws) {
|
foreach ($db->query('SELECT id, workshop_name, workshop_time FROM workshop_sessions')->fetchAll() as $ws) {
|
||||||
@@ -148,13 +271,16 @@ function handle_post(): never
|
|||||||
);
|
);
|
||||||
$insertStmt = $db->prepare(
|
$insertStmt = $db->prepare(
|
||||||
'INSERT INTO attendees (first_name, last_name, email, mobile_number)
|
'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(
|
$findByEmailStmt = $db->prepare(
|
||||||
"SELECT id FROM attendees WHERE email = :email AND email != ''"
|
"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'
|
'UPDATE attendees SET first_name=:first_name, last_name=:last_name, email=:email, mobile_number=:mobile_number WHERE id=:id'
|
||||||
);
|
);
|
||||||
@@ -164,52 +290,12 @@ function handle_post(): never
|
|||||||
|
|
||||||
$imported = 0;
|
$imported = 0;
|
||||||
$updated = 0;
|
$updated = 0;
|
||||||
$skipped = 0;
|
|
||||||
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
|
|
||||||
$rejected = [];
|
|
||||||
|
|
||||||
while (($row = fgetcsv($handle, 0, $delim)) !== false) {
|
foreach ($validRows as $vr) {
|
||||||
// Strip leading/trailing double-quotes that some spreadsheet exports add around field values
|
$firstName = $vr['first_name'];
|
||||||
$row = array_map(fn($v) => trim((string) $v, " \t\n\r\0\x0B\""), $row);
|
$lastName = $vr['last_name'];
|
||||||
|
$email = $vr['email'];
|
||||||
$firstName = trim((string) ($row[$col['first_name']] ?? ''));
|
$mobile = $vr['mobile_number'];
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($mode === 'clear') {
|
if ($mode === 'clear') {
|
||||||
$insertStmt->execute([
|
$insertStmt->execute([
|
||||||
@@ -219,9 +305,9 @@ function handle_post(): never
|
|||||||
':mobile_number' => $mobile,
|
':mobile_number' => $mobile,
|
||||||
]);
|
]);
|
||||||
$attendeeId = (int) $db->lastInsertId();
|
$attendeeId = (int) $db->lastInsertId();
|
||||||
$imported++;
|
// rowCount: 1 = new insert, 2 = ON DUPLICATE KEY UPDATE fired
|
||||||
|
if ($insertStmt->rowCount() >= 2) { $updated++; } else { $imported++; }
|
||||||
} else {
|
} else {
|
||||||
// Match by email only
|
|
||||||
$matches = [];
|
$matches = [];
|
||||||
if ($email !== '') {
|
if ($email !== '') {
|
||||||
$findByEmailStmt->execute([':email' => $email]);
|
$findByEmailStmt->execute([':email' => $email]);
|
||||||
@@ -229,7 +315,6 @@ function handle_post(): never
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (count($matches) === 0) {
|
if (count($matches) === 0) {
|
||||||
// Not found — insert
|
|
||||||
$insertStmt->execute([
|
$insertStmt->execute([
|
||||||
':first_name' => $firstName,
|
':first_name' => $firstName,
|
||||||
':last_name' => $lastName,
|
':last_name' => $lastName,
|
||||||
@@ -239,7 +324,6 @@ function handle_post(): never
|
|||||||
$attendeeId = (int) $db->lastInsertId();
|
$attendeeId = (int) $db->lastInsertId();
|
||||||
$imported++;
|
$imported++;
|
||||||
} else {
|
} else {
|
||||||
// overwrite: update all fields (incl. mobile), collapse any duplicates
|
|
||||||
$attendeeId = (int) $matches[0]['id'];
|
$attendeeId = (int) $matches[0]['id'];
|
||||||
$updateStmt->execute([
|
$updateStmt->execute([
|
||||||
':first_name' => $firstName,
|
':first_name' => $firstName,
|
||||||
@@ -259,42 +343,16 @@ function handle_post(): never
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign to a workshop session (runs for all modes)
|
if ($hasWorkshops && $vr['workshop_name'] !== '') {
|
||||||
if ($hasWorkshops) {
|
$cacheKey = $vr['workshop_name'] . '|' . ($vr['workshop_time'] ?? '');
|
||||||
$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])) {
|
if (!isset($workshopCache[$cacheKey])) {
|
||||||
$workshopStmt->execute([':name' => $wsName, ':time' => $dt]);
|
$workshopStmt->execute([':name' => $vr['workshop_name'], ':time' => $vr['workshop_time']]);
|
||||||
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
|
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
|
||||||
}
|
}
|
||||||
$regStmt->execute([
|
$regStmt->execute([':attendee_id' => $attendeeId, ':ws_id' => $workshopCache[$cacheKey]]);
|
||||||
':attendee_id' => $attendeeId,
|
|
||||||
':ws_id' => $workshopCache[$cacheKey],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
|
||||||
$db->commit();
|
$db->commit();
|
||||||
|
|
||||||
json_out(['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'skip_reasons' => $skipReasons, 'workshops_imported' => $hasWorkshops, 'rejected' => $rejected]);
|
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
|
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);
|
$body = json_decode((string) file_get_contents('php://input'), true);
|
||||||
|
|
||||||
$key = trim((string) ($body['key'] ?? ''));
|
$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="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||||
<div class="nav-link-custom" style="cursor:default;">
|
<div class="nav-link-custom" style="cursor:default;">
|
||||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,6 +240,9 @@
|
|||||||
<script>
|
<script>
|
||||||
Dropzone.autoDiscover = false;
|
Dropzone.autoDiscover = false;
|
||||||
|
|
||||||
|
let pendingFile = null;
|
||||||
|
let currentDuplicates = [];
|
||||||
|
|
||||||
const dz = new Dropzone("#csvDropzone", {
|
const dz = new Dropzone("#csvDropzone", {
|
||||||
url: "/api/attendees.php",
|
url: "/api/attendees.php",
|
||||||
acceptedFiles: ".csv",
|
acceptedFiles: ".csv",
|
||||||
@@ -270,8 +273,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
dz.on("success", (file, response) => {
|
dz.on("success", (file, response) => {
|
||||||
dz.removeFile(file);
|
|
||||||
const r = typeof response === 'string' ? JSON.parse(response) : 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 resultEl = document.getElementById('importResult');
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (r.imported) parts.push(`<span class="text-success">${r.imported} added</span>`);
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- ─── Edit attendee modal ─── -->
|
<!-- ─── Edit attendee modal ─── -->
|
||||||
@@ -639,13 +725,45 @@
|
|||||||
</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>
|
<script>
|
||||||
(async function () {
|
async function checkAuth() {
|
||||||
try {
|
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.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 { }
|
} catch { }
|
||||||
}());
|
}
|
||||||
|
checkAuth();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -90,7 +90,7 @@
|
|||||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||||
<div class="nav-link-custom" style="cursor:default;">
|
<div class="nav-link-custom" style="cursor:default;">
|
||||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,12 +245,21 @@
|
|||||||
loadLog();
|
loadLog();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(async function () {
|
async function checkAuth() {
|
||||||
try {
|
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.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 { }
|
} catch { }
|
||||||
}());
|
}
|
||||||
|
checkAuth();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+20
-5
@@ -89,7 +89,7 @@
|
|||||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||||
<div class="nav-link-custom" style="cursor:default;">
|
<div class="nav-link-custom" style="cursor:default;">
|
||||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,10 +197,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- Start Admin Settings -->
|
<!-- Start Admin Settings -->
|
||||||
|
<div id="adminSection" class="d-none col-12">
|
||||||
<hr class="mt-4 mb-3">
|
<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>
|
<p><em>You're seeing these options because you're an admin. Proceed with caution!</em></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
<div class="col-12 col-sm-6">
|
<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">
|
<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>
|
<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>
|
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /.row (admin cards) -->
|
||||||
|
</div><!-- /#adminSection -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,12 +336,23 @@
|
|||||||
loadStats();
|
loadStats();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(async function () {
|
async function checkAuth() {
|
||||||
try {
|
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.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 { }
|
} catch { }
|
||||||
}());
|
}
|
||||||
|
checkAuth();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -104,7 +104,7 @@
|
|||||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||||
<div class="nav-link-custom" style="cursor:default;">
|
<div class="nav-link-custom" style="cursor:default;">
|
||||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -501,12 +501,21 @@
|
|||||||
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(async function () {
|
async function checkAuth() {
|
||||||
try {
|
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.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 { }
|
} catch { }
|
||||||
}());
|
}
|
||||||
|
checkAuth();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -90,7 +90,7 @@
|
|||||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||||
<div class="nav-link-custom" style="cursor:default;">
|
<div class="nav-link-custom" style="cursor:default;">
|
||||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,12 +369,21 @@
|
|||||||
loadTemplatePicker();
|
loadTemplatePicker();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(async function () {
|
async function checkAuth() {
|
||||||
try {
|
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.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 { }
|
} catch { }
|
||||||
}());
|
}
|
||||||
|
checkAuth();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -90,7 +90,7 @@
|
|||||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||||
<div class="nav-link-custom" style="cursor:default;">
|
<div class="nav-link-custom" style="cursor:default;">
|
||||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,12 +296,21 @@
|
|||||||
loadTemplates();
|
loadTemplates();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(async function () {
|
async function checkAuth() {
|
||||||
try {
|
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.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 { }
|
} catch { }
|
||||||
}());
|
}
|
||||||
|
checkAuth();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user