feat: detect duplicates in csv
This commit is contained in:
+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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user