cleanup and harden
Build and Push Docker Image / build (push) Successful in 12s

This commit is contained in:
Jason Stedwell
2026-06-19 14:48:32 -05:00
parent cdca5557d1
commit d3a6390dc8
2 changed files with 682 additions and 457 deletions
+16 -4
View File
@@ -180,7 +180,7 @@ Per-controller actions in the modal:
| Control | Description | | Control | Description |
|---|---| |---|---|
| **Date picker** | Choose which day to view | | **Date picker** | Choose which day to view (defaults to the browser's local date) |
| **Badged in by** | Set your on-time cutoff (HH:MM) | | **Badged in by** | Set your on-time cutoff (HH:MM) |
| **Controller** | Filter the table to one controller, or show All | | **Controller** | Filter the table to one controller, or show All |
| **Show filtered** | Include filtered tenants in the table (dimmed and tagged) | | **Show filtered** | Include filtered tenants in the table (dimmed and tagged) |
@@ -209,6 +209,17 @@ Per-controller actions in the modal:
> controller the person badged into that day, plus a "MERGED" pill so it's > controller the person badged into that day, plus a "MERGED" pill so it's
> clear the row represents N UniFi UUIDs. > clear the row represents N UniFi UUIDs.
### Interface notes
- On narrow screens, the attendance table automatically changes into stacked
row cards with labels for each field, so all columns remain visible without
sideways scrolling.
- Destructive or structural actions such as removing a controller, dissolving a
merged person, splitting an identity, and resetting a day use in-app
confirmation dialogs instead of browser popups.
- Toast notifications announce success/failure states; keyboard users can close
open dialogs with `Esc`.
--- ---
## Merging identities across controllers ## Merging identities across controllers
@@ -225,9 +236,10 @@ fixes this:
with matching full names across different controllers. One click confirms with matching full names across different controllers. One click confirms
each suggestion (no auto-apply; you're always in the loop). each suggestion (no auto-apply; you're always in the loop).
- The same modal lists every merged person below the suggestions, with - The same modal lists every merged person below the suggestions, with
per-person actions: **Rename**, **Split off** (remove one identity from the per-person actions: **Rename** (opens an in-app text dialog), **Split off**
group), **Dissolve** (break the whole group up — past badge events are (remove one identity from the group), **Dissolve** (break the whole group up —
preserved, the identities just become standalone rows again). past badge events are preserved, the identities just become standalone rows
again).
Once merged, the attendance table: Once merged, the attendance table:
+355 -142
View File
@@ -243,14 +243,76 @@
.toast.error { border-color: rgba(255,100,100,0.6); color: #ffd6d7; } .toast.error { border-color: rgba(255,100,100,0.6); color: #ffd6d7; }
@media (max-width: 800px) { @media (max-width: 800px) {
body { padding: 12px; align-items: flex-start; }
.app-shell { padding: 18px 14px 22px; border-radius: 14px; }
header { flex-direction: column; align-items: flex-start; } header { flex-direction: column; align-items: flex-start; }
h1 { font-size: 1.25rem; letter-spacing: 0.04em; }
.subtitle { font-size: 0.82rem; }
.badge { letter-spacing: 0.08em; }
.controls { flex-direction: column; align-items: stretch; } .controls { flex-direction: column; align-items: stretch; }
.control-group { flex-direction: column; align-items: stretch; gap: 6px; }
input, select, button { width: 100%; } input, select, button { width: 100%; }
th:nth-child(5), td:nth-child(5), .show-filtered-toggle { flex-direction: row; align-items: center; width: 100%; }
th:nth-child(6), td:nth-child(6) { display: none; } .show-filtered-toggle input,
.row-action-btn,
.ctrl-actions button,
.picker-row button,
.subject-row input { width: auto; }
.summary-row { display: grid; grid-template-columns: 1fr; }
.table-card { background: transparent; border: 0; overflow: visible; }
.table-card table,
.table-card thead,
.table-card tbody,
.table-card tr,
.table-card td { display: block; width: 100%; }
.table-card thead { display: none; }
.table-card tbody { display: flex; flex-direction: column; gap: 12px; }
.table-card tbody tr {
background: linear-gradient(135deg, rgba(17,17,19,0.98), rgba(8,8,10,0.98));
border: 1px solid rgba(255,255,255,0.07);
border-radius: 12px;
padding: 10px 12px;
}
.table-card tbody tr:hover { background: linear-gradient(135deg, rgba(24,24,27,0.98), rgba(10,10,12,0.98)); }
.table-card td {
display: grid;
grid-template-columns: minmax(96px, 35%) 1fr;
gap: 12px;
align-items: start;
padding: 9px 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
white-space: normal;
text-align: right;
}
.table-card td:last-child { border-bottom: 0; }
.table-card td::before {
content: attr(data-label);
color: var(--muted);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
text-align: left;
}
.table-card .name-cell { font-size: 1rem; }
.table-card .align-center { text-align: right; }
.table-card .align-center .status-chip,
.table-card .align-center .row-action-group { justify-content: flex-end; margin-left: auto; }
.source-list { justify-content: flex-end; }
.source-chip, .merged-pill { max-width: 100%; overflow-wrap: anywhere; }
.row-action-group { flex-wrap: wrap; justify-content: flex-end; }
.table-card tbody tr.empty-table-row { padding: 0; }
.table-card tbody tr.empty-table-row td {
display: block;
text-align: center;
padding: 24px 14px;
border-bottom: 0;
}
.table-card tbody tr.empty-table-row td::before { content: none; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.ctrl-row { grid-template-columns: 1fr; } .ctrl-row { grid-template-columns: 1fr; }
.ctrl-actions { justify-content: flex-start; } .ctrl-actions { justify-content: flex-start; }
.modal { padding: 18px; width: 94%; border-radius: 12px; }
.modal-actions { flex-direction: column-reverse; }
} }
</style> </style>
</head> </head>
@@ -315,7 +377,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="table-body"> <tbody id="table-body">
<tr><td colspan="8" class="empty-state">No data yet. <span>Badge into a door</span> and press Refresh.</td></tr> <tr class="empty-table-row"><td colspan="8" class="empty-state">No data yet. <span>Badge into a door</span> and press Refresh.</td></tr>
</tbody> </tbody>
</table> </table>
</section> </section>
@@ -323,8 +385,8 @@
<!-- Reset confirmation modal --> <!-- Reset confirmation modal -->
<div class="modal-overlay" id="reset-modal"> <div class="modal-overlay" id="reset-modal">
<div class="modal danger"> <div class="modal danger" role="dialog" aria-modal="true" aria-labelledby="reset-title" tabindex="-1">
<h2>&#9888; Reset Day</h2> <h2 id="reset-title">&#9888; Reset Day</h2>
<p>This will permanently delete all badge-in records for <strong id="modal-date-label"></strong>.<br>Use this for testing only.</p> <p>This will permanently delete all badge-in records for <strong id="modal-date-label"></strong>.<br>Use this for testing only.</p>
<div class="modal-actions"> <div class="modal-actions">
<button class="modal-cancel" id="modal-cancel">Cancel</button> <button class="modal-cancel" id="modal-cancel">Cancel</button>
@@ -335,8 +397,8 @@
<!-- Controllers management modal --> <!-- Controllers management modal -->
<div class="modal-overlay" id="controllers-modal"> <div class="modal-overlay" id="controllers-modal">
<div class="modal"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="controllers-title" tabindex="-1">
<h2>Controllers</h2> <h2 id="controllers-title">Controllers</h2>
<p>Each controller is a UniFi Access instance reachable from this server. <p>Each controller is a UniFi Access instance reachable from this server.
Adding one will register a webhook on that controller automatically.</p> Adding one will register a webhook on that controller automatically.</p>
@@ -379,8 +441,8 @@
<!-- Merge picker modal --> <!-- Merge picker modal -->
<div class="modal-overlay" id="merge-modal"> <div class="modal-overlay" id="merge-modal">
<div class="modal"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="merge-title" tabindex="-1">
<h2>Merge with another actor</h2> <h2 id="merge-title">Merge with another actor</h2>
<p>Pick the other badge identity for <strong id="merge-source-name"></strong>. <p>Pick the other badge identity for <strong id="merge-source-name"></strong>.
Names are matched fuzzily; type to narrow.</p> Names are matched fuzzily; type to narrow.</p>
@@ -406,8 +468,8 @@
<!-- People management modal --> <!-- People management modal -->
<div class="modal-overlay" id="people-modal"> <div class="modal-overlay" id="people-modal">
<div class="modal"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="people-title" tabindex="-1">
<h2>People</h2> <h2 id="people-title">People</h2>
<p>A "person" groups one or more badge identities (controller + actor) so the <p>A "person" groups one or more badge identities (controller + actor) so the
same human counts as a single row in the attendance table.</p> same human counts as a single row in the attendance table.</p>
@@ -429,8 +491,8 @@
<!-- Filtered tenants modal --> <!-- Filtered tenants modal -->
<div class="modal-overlay" id="filtered-modal"> <div class="modal-overlay" id="filtered-modal">
<div class="modal"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="filtered-title" tabindex="-1">
<h2>Filtered Tenants</h2> <h2 id="filtered-title">Filtered Tenants</h2>
<p>These actors are hidden from the attendance table. Their badge events are <p>These actors are hidden from the attendance table. Their badge events are
still recorded — unhide to bring them back.</p> still recorded — unhide to bring them back.</p>
@@ -446,8 +508,8 @@
<!-- Report modal --> <!-- Report modal -->
<div class="modal-overlay" id="report-modal"> <div class="modal-overlay" id="report-modal">
<div class="modal wide"> <div class="modal wide" role="dialog" aria-modal="true" aria-labelledby="report-title" tabindex="-1">
<h2>First Badge-In Report</h2> <h2 id="report-title">First Badge-In Report</h2>
<p>First badge-in per day over a date range. Pick one person for an individual <p>First badge-in per day over a date range. Pick one person for an individual
report, or several for a group. Days with no badge-in show as <strong>absent</strong>.</p> report, or several for a group. Days with no badge-in show as <strong>absent</strong>.</p>
@@ -490,10 +552,45 @@
</div> </div>
</div> </div>
<div class="toast" id="toast"></div> <!-- Generic confirmation modal -->
<div class="modal-overlay" id="confirm-modal">
<div class="modal danger" role="dialog" aria-modal="true" aria-labelledby="confirm-title" tabindex="-1">
<h2 id="confirm-title">Confirm</h2>
<p id="confirm-message"></p>
<div class="modal-actions">
<button class="modal-cancel" id="confirm-cancel">Cancel</button>
<button class="modal-confirm" id="confirm-ok">Confirm</button>
</div>
</div>
</div>
<!-- Generic text input modal -->
<div class="modal-overlay" id="text-modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="text-title" tabindex="-1">
<h2 id="text-title">Update</h2>
<div class="form-grid">
<div class="full">
<label for="text-input" id="text-label">Value</label>
<input type="text" id="text-input">
</div>
</div>
<div class="form-error" id="text-error"></div>
<div class="modal-actions">
<button class="modal-cancel" id="text-cancel">Cancel</button>
<button id="text-ok">Save</button>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script> <script>
function isoToday() { return new Date().toISOString().slice(0, 10); } function isoToday(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function showToast(msg, isError = false, duration = 3500) { function showToast(msg, isError = false, duration = 3500) {
const t = document.getElementById('toast'); const t = document.getElementById('toast');
@@ -504,12 +601,135 @@
t._timer = setTimeout(() => t.classList.remove('show'), duration); t._timer = setTimeout(() => t.classList.remove('show'), duration);
} }
async function apiFetch(url, options = {}) {
const { json, expectJson = true, ...fetchOptions } = options;
const headers = new Headers(fetchOptions.headers || {});
if (json !== undefined) {
headers.set('Content-Type', 'application/json');
fetchOptions.body = JSON.stringify(json);
}
fetchOptions.headers = headers;
let res;
try {
res = await fetch(url, fetchOptions);
} catch (e) {
throw new Error(`Network error: ${e.message || e}`);
}
const contentType = res.headers.get('content-type') || '';
let payload = null;
if (expectJson) {
if (contentType.includes('application/json')) {
payload = await res.json().catch(() => null);
} else {
const text = await res.text().catch(() => '');
payload = text ? { error: text } : null;
}
} else {
payload = await res.text();
}
if (!res.ok) {
const detail = payload && (payload.error || payload.message || payload.response);
const err = new Error(detail ? String(detail) : `Request failed (${res.status})`);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
function openModal(id, focusSelector) {
const overlay = document.getElementById(id);
overlay.classList.add('open');
const target = focusSelector
? overlay.querySelector(focusSelector)
: overlay.querySelector('.modal');
if (target) requestAnimationFrame(() => target.focus());
}
function closeModal(id) {
document.getElementById(id).classList.remove('open');
}
let confirmResolver = null;
function askConfirm({ title, message, confirmText = 'Confirm' }) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-message').textContent = message;
document.getElementById('confirm-ok').textContent = confirmText;
openModal('confirm-modal', '#confirm-cancel');
return new Promise(resolve => { confirmResolver = resolve; });
}
function resolveConfirm(value) {
closeModal('confirm-modal');
if (confirmResolver) {
confirmResolver(value);
confirmResolver = null;
}
}
let textResolver = null;
function askText({ title, label, value = '', confirmText = 'Save' }) {
document.getElementById('text-title').textContent = title;
document.getElementById('text-label').textContent = label;
document.getElementById('text-input').value = value;
document.getElementById('text-error').textContent = '';
document.getElementById('text-ok').textContent = confirmText;
openModal('text-modal', '#text-input');
return new Promise(resolve => { textResolver = resolve; });
}
function resolveText(value) {
closeModal('text-modal');
if (textResolver) {
textResolver(value);
textResolver = null;
}
}
function closeTopModal() {
const openModals = Array.from(document.querySelectorAll('.modal-overlay.open'));
const top = openModals[openModals.length - 1];
if (!top) return;
if (top.id === 'confirm-modal') resolveConfirm(false);
else if (top.id === 'text-modal') resolveText(null);
else closeModal(top.id);
}
document.getElementById('confirm-cancel').addEventListener('click', () => resolveConfirm(false));
document.getElementById('confirm-ok').addEventListener('click', () => resolveConfirm(true));
document.getElementById('confirm-modal').addEventListener('click', e => {
if (e.target.id === 'confirm-modal') resolveConfirm(false);
});
document.getElementById('text-cancel').addEventListener('click', () => resolveText(null));
document.getElementById('text-ok').addEventListener('click', () => {
const input = document.getElementById('text-input');
const value = input.value.trim();
if (!value) {
document.getElementById('text-error').textContent = 'Value is required.';
input.focus();
return;
}
resolveText(value);
});
document.getElementById('text-input').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('text-ok').click();
});
document.getElementById('text-modal').addEventListener('click', e => {
if (e.target.id === 'text-modal') resolveText(null);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeTopModal();
});
async function loadControllerList() { async function loadControllerList() {
const sel = document.getElementById('controller-filter'); const sel = document.getElementById('controller-filter');
const prev = sel.value; const prev = sel.value;
try { try {
const res = await fetch('/api/controllers'); const items = await apiFetch('/api/controllers');
const items = await res.json();
sel.innerHTML = '<option value="">All controllers</option>' + sel.innerHTML = '<option value="">All controllers</option>' +
items.map(c => `<option value="${c.id}">${escapeHtml(c.name)}${c.enabled ? '' : ' (disabled)'}</option>`).join(''); items.map(c => `<option value="${c.id}">${escapeHtml(c.name)}${c.enabled ? '' : ' (disabled)'}</option>`).join('');
if (prev && items.some(c => c.id === prev)) sel.value = prev; if (prev && items.some(c => c.id === prev)) sel.value = prev;
@@ -530,10 +750,9 @@
let data; let data;
try { try {
const res = await fetch('/api/first-badge-status?' + params.toString()); data = await apiFetch('/api/first-badge-status?' + params.toString());
data = await res.json(); } catch (e) {
} catch { showToast(`Could not load data: ${e.message}`, true);
showToast('Could not load data', true);
return; return;
} }
@@ -541,7 +760,7 @@
tbody.innerHTML = ''; tbody.innerHTML = '';
if (!data.length) { if (!data.length) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">No badge-in records for this day.</td></tr>'; tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No badge-in records for this day.</td></tr>';
['on-time-count','late-count','total-count'].forEach(id => { ['on-time-count','late-count','total-count'].forEach(id => {
document.getElementById(id).textContent = '0'; document.getElementById(id).textContent = '0';
}); });
@@ -556,15 +775,18 @@
const numTd = document.createElement('td'); const numTd = document.createElement('td');
numTd.className = 'muted-cell'; numTd.className = 'muted-cell';
numTd.dataset.label = '#';
numTd.textContent = i + 1; numTd.textContent = i + 1;
tr.appendChild(numTd); tr.appendChild(numTd);
const nameTd = document.createElement('td'); const nameTd = document.createElement('td');
nameTd.className = 'name-cell'; nameTd.className = 'name-cell';
nameTd.dataset.label = 'Name';
nameTd.textContent = row.name; nameTd.textContent = row.name;
tr.appendChild(nameTd); tr.appendChild(nameTd);
const sourceTd = document.createElement('td'); const sourceTd = document.createElement('td');
sourceTd.dataset.label = 'Source';
const sourceWrap = document.createElement('span'); const sourceWrap = document.createElement('span');
sourceWrap.className = 'source-list'; sourceWrap.className = 'source-list';
const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—']; const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—'];
@@ -585,10 +807,12 @@
const firstTd = document.createElement('td'); const firstTd = document.createElement('td');
firstTd.className = 'time-first'; firstTd.className = 'time-first';
firstTd.dataset.label = 'First Badge In';
firstTd.textContent = row.first_ts || '—'; firstTd.textContent = row.first_ts || '—';
tr.appendChild(firstTd); tr.appendChild(firstTd);
const latestTd = document.createElement('td'); const latestTd = document.createElement('td');
latestTd.dataset.label = 'Latest Badge In';
if (!row.latest_ts) { if (!row.latest_ts) {
latestTd.className = 'same-badge'; latestTd.className = 'same-badge';
latestTd.textContent = '— same'; latestTd.textContent = '— same';
@@ -600,11 +824,13 @@
const idTd = document.createElement('td'); const idTd = document.createElement('td');
idTd.className = 'muted-cell'; idTd.className = 'muted-cell';
idTd.dataset.label = 'Actor ID';
idTd.textContent = row.actor_id ? row.actor_id.slice(0, 8) + '...' : '—'; idTd.textContent = row.actor_id ? row.actor_id.slice(0, 8) + '...' : '—';
tr.appendChild(idTd); tr.appendChild(idTd);
const statusTd = document.createElement('td'); const statusTd = document.createElement('td');
statusTd.className = 'align-center'; statusTd.className = 'align-center';
statusTd.dataset.label = 'Status';
const statusChip = document.createElement('div'); const statusChip = document.createElement('div');
const isOnTime = row.status === 'ON TIME'; const isOnTime = row.status === 'ON TIME';
statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off'); statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
@@ -614,6 +840,7 @@
const actionTd = document.createElement('td'); const actionTd = document.createElement('td');
actionTd.className = 'align-center'; actionTd.className = 'align-center';
actionTd.dataset.label = 'Actions';
const actionGroup = document.createElement('div'); const actionGroup = document.createElement('div');
actionGroup.className = 'row-action-group'; actionGroup.className = 'row-action-group';
@@ -653,28 +880,26 @@
showToast('Missing controller or actor id', true); showToast('Missing controller or actor id', true);
return false; return false;
} }
const r = await fetch(`/api/users/${controllerId}/${actorId}`, { try {
await apiFetch(`/api/users/${controllerId}/${actorId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, json: { filtered },
body: JSON.stringify({ filtered }),
}); });
if (!r.ok) { } catch (e) {
const j = await r.json().catch(() => ({})); showToast(`Failed: ${e.message}`, true);
showToast(`Failed: ${j.error || r.status}`, true);
return false; return false;
} }
return true; return true;
} }
async function setPersonFiltered(personId, filtered) { async function setPersonFiltered(personId, filtered) {
const r = await fetch(`/api/persons/${personId}`, { try {
await apiFetch(`/api/persons/${personId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, json: { filtered },
body: JSON.stringify({ filtered }),
}); });
if (!r.ok) { } catch (e) {
const j = await r.json().catch(() => ({})); showToast(`Failed: ${e.message}`, true);
showToast(`Failed: ${j.error || r.status}`, true);
return false; return false;
} }
return true; return true;
@@ -703,7 +928,7 @@
if (mergeBtn) { if (mergeBtn) {
if (mergeBtn.dataset.personId) { if (mergeBtn.dataset.personId) {
// Already merged — open People modal scrolled to this person // Already merged — open People modal scrolled to this person
document.getElementById('people-modal').classList.add('open'); openModal('people-modal', '#people-close');
await renderSuggestions(); await renderSuggestions();
await renderPeople(mergeBtn.dataset.personId); await renderPeople(mergeBtn.dataset.personId);
} else { } else {
@@ -721,11 +946,11 @@
const orig = btn.innerHTML; const orig = btn.innerHTML;
btn.textContent = 'Syncing…'; btn.disabled = true; btn.textContent = 'Syncing…'; btn.disabled = true;
try { try {
await fetch('/api/sync-users'); await apiFetch('/api/sync-users');
showToast('User list synced from all controllers'); showToast('User list synced from all controllers');
await loadData(); await loadData();
} catch { } catch (e) {
showToast('Sync failed — check server logs', true); showToast(`Sync failed: ${e.message}`, true);
} finally { } finally {
btn.innerHTML = orig; btn.disabled = false; btn.innerHTML = orig; btn.disabled = false;
} }
@@ -735,29 +960,28 @@
document.getElementById('reset-btn').addEventListener('click', () => { document.getElementById('reset-btn').addEventListener('click', () => {
const date = document.getElementById('date').value || isoToday(); const date = document.getElementById('date').value || isoToday();
document.getElementById('modal-date-label').textContent = date; document.getElementById('modal-date-label').textContent = date;
document.getElementById('reset-modal').classList.add('open'); openModal('reset-modal', '#modal-cancel');
}); });
document.getElementById('modal-cancel').addEventListener('click', () => { document.getElementById('modal-cancel').addEventListener('click', () => {
document.getElementById('reset-modal').classList.remove('open'); closeModal('reset-modal');
}); });
document.getElementById('modal-confirm').addEventListener('click', async () => { document.getElementById('modal-confirm').addEventListener('click', async () => {
document.getElementById('reset-modal').classList.remove('open'); closeModal('reset-modal');
const date = document.getElementById('date').value || isoToday(); const date = document.getElementById('date').value || isoToday();
const controllerId = document.getElementById('controller-filter').value; const controllerId = document.getElementById('controller-filter').value;
const params = new URLSearchParams({ date }); const params = new URLSearchParams({ date });
if (controllerId) params.set('controller_id', controllerId); if (controllerId) params.set('controller_id', controllerId);
try { try {
const res = await fetch('/api/reset-day?' + params.toString(), { method: 'DELETE' }); const json = await apiFetch('/api/reset-day?' + params.toString(), { method: 'DELETE' });
const json = await res.json();
showToast(`Reset complete — ${json.deleted} record(s) deleted for ${date}`); showToast(`Reset complete — ${json.deleted} record(s) deleted for ${date}`);
await loadData(); await loadData();
} catch { } catch (e) {
showToast('Reset failed — check server logs', true); showToast(`Reset failed: ${e.message}`, true);
} }
}); });
document.getElementById('reset-modal').addEventListener('click', e => { document.getElementById('reset-modal').addEventListener('click', e => {
if (e.target === document.getElementById('reset-modal')) if (e.target === document.getElementById('reset-modal'))
document.getElementById('reset-modal').classList.remove('open'); closeModal('reset-modal');
}); });
// -------- Controllers modal -------- // -------- Controllers modal --------
@@ -779,8 +1003,7 @@
let items = []; let items = [];
try { try {
const res = await fetch('/api/controllers'); items = await apiFetch('/api/controllers');
items = await res.json();
} catch { } catch {
list.innerHTML = '<div class="empty-state">Failed to load controllers.</div>'; list.innerHTML = '<div class="empty-state">Failed to load controllers.</div>';
return; return;
@@ -817,17 +1040,17 @@
document.getElementById('open-controllers-btn').addEventListener('click', async () => { document.getElementById('open-controllers-btn').addEventListener('click', async () => {
document.getElementById('base-url-hint').textContent = window.location.origin; document.getElementById('base-url-hint').textContent = window.location.origin;
document.getElementById('add-error').textContent = ''; document.getElementById('add-error').textContent = '';
document.getElementById('controllers-modal').classList.add('open'); openModal('controllers-modal', '#controllers-close');
await renderControllers(); await renderControllers();
}); });
document.getElementById('controllers-close').addEventListener('click', () => { document.getElementById('controllers-close').addEventListener('click', () => {
document.getElementById('controllers-modal').classList.remove('open'); closeModal('controllers-modal');
}); });
document.getElementById('controllers-modal').addEventListener('click', e => { document.getElementById('controllers-modal').addEventListener('click', e => {
if (e.target === document.getElementById('controllers-modal')) if (e.target === document.getElementById('controllers-modal'))
document.getElementById('controllers-modal').classList.remove('open'); closeModal('controllers-modal');
}); });
document.getElementById('ctrl-list').addEventListener('click', async e => { document.getElementById('ctrl-list').addEventListener('click', async e => {
@@ -838,37 +1061,34 @@
btn.disabled = true; btn.disabled = true;
try { try {
if (act === 'test') { if (act === 'test') {
const r = await fetch(`/api/controllers/${id}/test`, { method: 'POST' }); const j = await apiFetch(`/api/controllers/${id}/test`, { method: 'POST' });
const j = await r.json();
showToast(j.ok showToast(j.ok
? `Connected — ${j.user_count} users on controller` ? `Connected — ${j.user_count} users on controller`
: `Test failed: ${j.message}`, : `Test failed: ${j.message}`,
!j.ok); !j.ok);
} else if (act === 'sync') { } else if (act === 'sync') {
const r = await fetch(`/api/controllers/${id}/sync`, { method: 'POST' }); const j = await apiFetch(`/api/controllers/${id}/sync`, { method: 'POST' });
const j = await r.json();
showToast(`Synced ${j.synced} users`); showToast(`Synced ${j.synced} users`);
await renderControllers(); await renderControllers();
await loadData(); await loadData();
} else if (act === 'toggle') { } else if (act === 'toggle') {
const isEnabling = btn.textContent.trim().toLowerCase() === 'enable'; const isEnabling = btn.textContent.trim().toLowerCase() === 'enable';
await fetch(`/api/controllers/${id}`, { await apiFetch(`/api/controllers/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, json: { enabled: isEnabling },
body: JSON.stringify({ enabled: isEnabling }),
}); });
await renderControllers(); await renderControllers();
await loadControllerList(); await loadControllerList();
} else if (act === 'delete') { } else if (act === 'delete') {
const name = btn.dataset.name; const name = btn.dataset.name;
if (!confirm(`Remove controller "${name}"?\n\nThis deletes its webhook from the controller and removes all its badge events from the dashboard. This cannot be undone.`)) const ok = await askConfirm({
title: 'Remove Controller',
message: `Remove controller "${name}"? This deletes its webhook from the controller and removes all its badge events from the dashboard. This cannot be undone.`,
confirmText: 'Remove',
});
if (!ok)
return; return;
const r = await fetch(`/api/controllers/${id}`, { method: 'DELETE' }); await apiFetch(`/api/controllers/${id}`, { method: 'DELETE' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
showToast(`Remove failed: ${j.error || r.status}`, true);
return;
}
showToast(`Removed ${name}`); showToast(`Removed ${name}`);
await renderControllers(); await renderControllers();
await loadControllerList(); await loadControllerList();
@@ -900,17 +1120,10 @@
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Adding…'; btn.textContent = 'Adding…';
try { try {
const r = await fetch('/api/controllers', { const j = await apiFetch('/api/controllers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, json: body,
body: JSON.stringify(body),
}); });
const j = await r.json();
if (!r.ok) {
err.textContent = j.error || 'Failed to add controller.';
if (j.response) err.textContent += `${String(j.response).slice(0, 200)}`;
return;
}
document.getElementById('add-name').value = ''; document.getElementById('add-name').value = '';
document.getElementById('add-host').value = ''; document.getElementById('add-host').value = '';
document.getElementById('add-token').value = ''; document.getElementById('add-token').value = '';
@@ -919,7 +1132,9 @@
await loadControllerList(); await loadControllerList();
await loadData(); await loadData();
} catch (e) { } catch (e) {
err.textContent = `Network error: ${e.message || e}`; err.textContent = e.message || 'Failed to add controller.';
if (e.payload && e.payload.response)
err.textContent += `${String(e.payload.response).slice(0, 200)}`;
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Add Controller'; btn.textContent = 'Add Controller';
@@ -932,8 +1147,7 @@
list.innerHTML = '<div class="empty-state">Loading…</div>'; list.innerHTML = '<div class="empty-state">Loading…</div>';
let items = []; let items = [];
try { try {
const res = await fetch('/api/users?filtered=1'); items = await apiFetch('/api/users?filtered=1');
items = await res.json();
} catch { } catch {
list.innerHTML = '<div class="empty-state">Failed to load filtered tenants.</div>'; list.innerHTML = '<div class="empty-state">Failed to load filtered tenants.</div>';
return; return;
@@ -963,15 +1177,15 @@
} }
document.getElementById('open-filtered-btn').addEventListener('click', async () => { document.getElementById('open-filtered-btn').addEventListener('click', async () => {
document.getElementById('filtered-modal').classList.add('open'); openModal('filtered-modal', '#filtered-close');
await renderFiltered(); await renderFiltered();
}); });
document.getElementById('filtered-close').addEventListener('click', () => { document.getElementById('filtered-close').addEventListener('click', () => {
document.getElementById('filtered-modal').classList.remove('open'); closeModal('filtered-modal');
}); });
document.getElementById('filtered-modal').addEventListener('click', e => { document.getElementById('filtered-modal').addEventListener('click', e => {
if (e.target === document.getElementById('filtered-modal')) if (e.target === document.getElementById('filtered-modal'))
document.getElementById('filtered-modal').classList.remove('open'); closeModal('filtered-modal');
}); });
document.getElementById('filtered-list').addEventListener('click', async e => { document.getElementById('filtered-list').addEventListener('click', async e => {
const btn = e.target.closest('button[data-act="unhide"]'); const btn = e.target.closest('button[data-act="unhide"]');
@@ -1042,19 +1256,17 @@
document.getElementById('merge-display-name').value = source.name; document.getElementById('merge-display-name').value = source.name;
document.getElementById('merge-search').value = ''; document.getElementById('merge-search').value = '';
document.getElementById('merge-error').textContent = ''; document.getElementById('merge-error').textContent = '';
document.getElementById('merge-modal').classList.add('open'); openModal('merge-modal', '#merge-search');
try { try {
const res = await fetch('/api/users'); mergeCandidates = await apiFetch('/api/users');
mergeCandidates = await res.json();
} catch { } catch {
mergeCandidates = []; mergeCandidates = [];
} }
// Only show actors not already in a person, by querying the persons API // Only show actors not already in a person, by querying the persons API
let merged = []; let merged = [];
try { try {
const res = await fetch('/api/persons'); const persons = await apiFetch('/api/persons');
const persons = await res.json();
merged = persons.flatMap(p => p.members.map(m => `${m.controller_id}|${m.actor_id}`)); merged = persons.flatMap(p => p.members.map(m => `${m.controller_id}|${m.actor_id}`));
} catch {} } catch {}
const mergedSet = new Set(merged); const mergedSet = new Set(merged);
@@ -1074,11 +1286,11 @@
renderMergeCandidates(); renderMergeCandidates();
}); });
document.getElementById('merge-cancel').addEventListener('click', () => { document.getElementById('merge-cancel').addEventListener('click', () => {
document.getElementById('merge-modal').classList.remove('open'); closeModal('merge-modal');
}); });
document.getElementById('merge-modal').addEventListener('click', e => { document.getElementById('merge-modal').addEventListener('click', e => {
if (e.target === document.getElementById('merge-modal')) if (e.target === document.getElementById('merge-modal'))
document.getElementById('merge-modal').classList.remove('open'); closeModal('merge-modal');
}); });
document.getElementById('merge-confirm').addEventListener('click', async () => { document.getElementById('merge-confirm').addEventListener('click', async () => {
const err = document.getElementById('merge-error'); const err = document.getElementById('merge-error');
@@ -1095,25 +1307,21 @@
const btn = document.getElementById('merge-confirm'); const btn = document.getElementById('merge-confirm');
btn.disabled = true; btn.textContent = 'Merging…'; btn.disabled = true; btn.textContent = 'Merging…';
try { try {
const r = await fetch('/api/persons', { await apiFetch('/api/persons', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, json: {
body: JSON.stringify({
display_name: displayName, display_name: displayName,
members: [ members: [
{ controller_id: mergeSource.controllerId, actor_id: mergeSource.actorId }, { controller_id: mergeSource.controllerId, actor_id: mergeSource.actorId },
{ controller_id: mergeSelected.controllerId, actor_id: mergeSelected.actorId }, { controller_id: mergeSelected.controllerId, actor_id: mergeSelected.actorId },
], ],
}), },
}); });
const j = await r.json(); closeModal('merge-modal');
if (!r.ok) {
err.textContent = j.error || `Failed (${r.status})`;
return;
}
document.getElementById('merge-modal').classList.remove('open');
showToast(`Merged into "${displayName}"`); showToast(`Merged into "${displayName}"`);
await loadData(); await loadData();
} catch (e) {
err.textContent = e.message || 'Merge failed';
} finally { } finally {
btn.disabled = false; btn.textContent = 'Merge'; btn.disabled = false; btn.textContent = 'Merge';
} }
@@ -1125,8 +1333,7 @@
list.innerHTML = '<div class="empty-state">Loading…</div>'; list.innerHTML = '<div class="empty-state">Loading…</div>';
let items = []; let items = [];
try { try {
const res = await fetch('/api/persons/suggestions'); items = await apiFetch('/api/persons/suggestions');
items = await res.json();
} catch { } catch {
list.innerHTML = '<div class="empty-state">Failed to load suggestions.</div>'; list.innerHTML = '<div class="empty-state">Failed to load suggestions.</div>';
return; return;
@@ -1157,8 +1364,7 @@
list.innerHTML = '<div class="empty-state">Loading…</div>'; list.innerHTML = '<div class="empty-state">Loading…</div>';
let items = []; let items = [];
try { try {
const res = await fetch('/api/persons'); items = await apiFetch('/api/persons');
items = await res.json();
} catch { } catch {
list.innerHTML = '<div class="empty-state">Failed to load people.</div>'; list.innerHTML = '<div class="empty-state">Failed to load people.</div>';
return; return;
@@ -1205,16 +1411,16 @@
} }
document.getElementById('open-people-btn').addEventListener('click', async () => { document.getElementById('open-people-btn').addEventListener('click', async () => {
document.getElementById('people-modal').classList.add('open'); openModal('people-modal', '#people-close');
await renderSuggestions(); await renderSuggestions();
await renderPeople(); await renderPeople();
}); });
document.getElementById('people-close').addEventListener('click', () => { document.getElementById('people-close').addEventListener('click', () => {
document.getElementById('people-modal').classList.remove('open'); closeModal('people-modal');
}); });
document.getElementById('people-modal').addEventListener('click', e => { document.getElementById('people-modal').addEventListener('click', e => {
if (e.target === document.getElementById('people-modal')) if (e.target === document.getElementById('people-modal'))
document.getElementById('people-modal').classList.remove('open'); closeModal('people-modal');
}); });
document.getElementById('suggestions-list').addEventListener('click', async e => { document.getElementById('suggestions-list').addEventListener('click', async e => {
@@ -1225,25 +1431,21 @@
try { payload = JSON.parse(btn.dataset.payload); } try { payload = JSON.parse(btn.dataset.payload); }
catch { btn.disabled = false; return; } catch { btn.disabled = false; return; }
try { try {
const r = await fetch('/api/persons', { await apiFetch('/api/persons', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, json: {
body: JSON.stringify({
display_name: payload.display_name, display_name: payload.display_name,
members: payload.members.map(m => ({ members: payload.members.map(m => ({
controller_id: m.controller_id, actor_id: m.actor_id, controller_id: m.controller_id, actor_id: m.actor_id,
})), })),
}), },
}); });
const j = await r.json();
if (!r.ok) {
showToast(`Merge failed: ${j.error || r.status}`, true);
return;
}
showToast(`Merged "${payload.display_name}"`); showToast(`Merged "${payload.display_name}"`);
await renderSuggestions(); await renderSuggestions();
await renderPeople(); await renderPeople();
await loadData(); await loadData();
} catch (e) {
showToast(`Merge failed: ${e.message}`, true);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@@ -1256,37 +1458,50 @@
btn.disabled = true; btn.disabled = true;
try { try {
if (act === 'rename') { if (act === 'rename') {
const name = prompt('New display name:', btn.dataset.name); const name = await askText({
if (!name || !name.trim()) return; title: 'Rename Person',
const r = await fetch(`/api/persons/${btn.dataset.personId}`, { label: 'Display name',
method: 'PATCH', value: btn.dataset.name,
headers: { 'Content-Type': 'application/json' }, confirmText: 'Rename',
body: JSON.stringify({ display_name: name.trim() }), });
if (!name || !name.trim()) return;
await apiFetch(`/api/persons/${btn.dataset.personId}`, {
method: 'PATCH',
json: { display_name: name.trim() },
}); });
if (!r.ok) { showToast('Rename failed', true); return; }
showToast(`Renamed to "${name.trim()}"`); showToast(`Renamed to "${name.trim()}"`);
await renderPeople(); await renderPeople();
await loadData(); await loadData();
} else if (act === 'dissolve') { } else if (act === 'dissolve') {
if (!confirm(`Dissolve merged person "${btn.dataset.name}"?\n\nAll members will become standalone actors again. Past badge events are unaffected.`)) const ok = await askConfirm({
title: 'Dissolve Person',
message: `Dissolve merged person "${btn.dataset.name}"? All members will become standalone actors again. Past badge events are unaffected.`,
confirmText: 'Dissolve',
});
if (!ok)
return; return;
const r = await fetch(`/api/persons/${btn.dataset.personId}`, { method: 'DELETE' }); await apiFetch(`/api/persons/${btn.dataset.personId}`, { method: 'DELETE' });
if (!r.ok) { showToast('Dissolve failed', true); return; }
showToast(`Dissolved "${btn.dataset.name}"`); showToast(`Dissolved "${btn.dataset.name}"`);
await renderSuggestions(); await renderSuggestions();
await renderPeople(); await renderPeople();
await loadData(); await loadData();
} else if (act === 'unmerge-member') { } else if (act === 'unmerge-member') {
if (!confirm(`Split "${btn.dataset.name}" off from this person?`)) const ok = await askConfirm({
title: 'Split Identity',
message: `Split "${btn.dataset.name}" off from this person?`,
confirmText: 'Split Off',
});
if (!ok)
return; return;
const url = `/api/persons/${btn.dataset.personId}/members/${btn.dataset.controllerId}/${btn.dataset.actorId}`; const url = `/api/persons/${btn.dataset.personId}/members/${btn.dataset.controllerId}/${btn.dataset.actorId}`;
const r = await fetch(url, { method: 'DELETE' }); await apiFetch(url, { method: 'DELETE' });
if (!r.ok) { showToast('Split failed', true); return; }
showToast(`Split off ${btn.dataset.name}`); showToast(`Split off ${btn.dataset.name}`);
await renderSuggestions(); await renderSuggestions();
await renderPeople(); await renderPeople();
await loadData(); await loadData();
} }
} catch (e) {
showToast(`Action failed: ${e.message}`, true);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@@ -1341,11 +1556,10 @@
if (cid) p.set('controller_id', cid); if (cid) p.set('controller_id', cid);
if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1'); if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1');
try { try {
const res = await fetch('/api/report/subjects?' + p.toString()); reportSubjects = await apiFetch('/api/report/subjects?' + p.toString());
reportSubjects = await res.json(); } catch (e) {
} catch {
reportSubjects = []; reportSubjects = [];
showToast('Could not load users', true); showToast(`Could not load users: ${e.message}`, true);
} }
renderReportSubjects(); renderReportSubjects();
} }
@@ -1353,14 +1567,13 @@
async function runReport() { async function runReport() {
const errEl = document.getElementById('report-error'); const errEl = document.getElementById('report-error');
errEl.textContent = ''; errEl.textContent = '';
const res = await fetch('/api/report?' + reportParams().toString()); try {
let data; const data = await apiFetch('/api/report?' + reportParams().toString());
try { data = await res.json(); } catch { data = null; } renderPivot(data);
if (!res.ok || !data) { } catch (e) {
errEl.textContent = (data && data.error) || 'Report failed'; errEl.textContent = e.message || 'Report failed';
return; return;
} }
renderPivot(data);
} }
function renderPivot(data) { function renderPivot(data) {
@@ -1413,7 +1626,7 @@
function openReportModal() { function openReportModal() {
const today = isoToday(); const today = isoToday();
const start = new Date(); start.setDate(start.getDate() - 6); const start = new Date(); start.setDate(start.getDate() - 6);
document.getElementById('report-start').value = start.toISOString().slice(0, 10); document.getElementById('report-start').value = isoToday(start);
document.getElementById('report-end').value = today; document.getElementById('report-end').value = today;
document.getElementById('report-cutoff').value = document.getElementById('report-cutoff').value =
document.getElementById('cutoff').value || '09:00'; document.getElementById('cutoff').value || '09:00';
@@ -1423,16 +1636,16 @@
document.getElementById('report-result').innerHTML = ''; document.getElementById('report-result').innerHTML = '';
reportSelected.clear(); reportSelected.clear();
updateReportSelCount(); updateReportSelCount();
document.getElementById('report-modal').classList.add('open'); openModal('report-modal', '#report-search');
loadReportSubjects(); loadReportSubjects();
} }
document.getElementById('open-report-btn').addEventListener('click', openReportModal); document.getElementById('open-report-btn').addEventListener('click', openReportModal);
document.getElementById('report-close').addEventListener('click', () => { document.getElementById('report-close').addEventListener('click', () => {
document.getElementById('report-modal').classList.remove('open'); closeModal('report-modal');
}); });
document.getElementById('report-modal').addEventListener('click', e => { document.getElementById('report-modal').addEventListener('click', e => {
if (e.target.id === 'report-modal') e.currentTarget.classList.remove('open'); if (e.target.id === 'report-modal') closeModal('report-modal');
}); });
document.getElementById('report-search').addEventListener('input', renderReportSubjects); document.getElementById('report-search').addEventListener('input', renderReportSubjects);
document.getElementById('report-subjects').addEventListener('change', e => { document.getElementById('report-subjects').addEventListener('change', e => {