This commit is contained in:
@@ -180,7 +180,7 @@ Per-controller actions in the modal:
|
||||
|
||||
| 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) |
|
||||
| **Controller** | Filter the table to one controller, or show All |
|
||||
| **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
|
||||
> 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
|
||||
@@ -225,9 +236,10 @@ fixes this:
|
||||
with matching full names across different controllers. One click confirms
|
||||
each suggestion (no auto-apply; you're always in the loop).
|
||||
- The same modal lists every merged person below the suggestions, with
|
||||
per-person actions: **Rename**, **Split off** (remove one identity from the
|
||||
group), **Dissolve** (break the whole group up — past badge events are
|
||||
preserved, the identities just become standalone rows again).
|
||||
per-person actions: **Rename** (opens an in-app text dialog), **Split off**
|
||||
(remove one identity from the group), **Dissolve** (break the whole group up —
|
||||
past badge events are preserved, the identities just become standalone rows
|
||||
again).
|
||||
|
||||
Once merged, the attendance table:
|
||||
|
||||
|
||||
+355
-142
@@ -243,14 +243,76 @@
|
||||
.toast.error { border-color: rgba(255,100,100,0.6); color: #ffd6d7; }
|
||||
|
||||
@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; }
|
||||
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; }
|
||||
.control-group { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
input, select, button { width: 100%; }
|
||||
th:nth-child(5), td:nth-child(5),
|
||||
th:nth-child(6), td:nth-child(6) { display: none; }
|
||||
.show-filtered-toggle { flex-direction: row; align-items: center; width: 100%; }
|
||||
.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; }
|
||||
.ctrl-row { grid-template-columns: 1fr; }
|
||||
.ctrl-actions { justify-content: flex-start; }
|
||||
.modal { padding: 18px; width: 94%; border-radius: 12px; }
|
||||
.modal-actions { flex-direction: column-reverse; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -315,7 +377,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
</section>
|
||||
@@ -323,8 +385,8 @@
|
||||
|
||||
<!-- Reset confirmation modal -->
|
||||
<div class="modal-overlay" id="reset-modal">
|
||||
<div class="modal danger">
|
||||
<h2>⚠ Reset Day</h2>
|
||||
<div class="modal danger" role="dialog" aria-modal="true" aria-labelledby="reset-title" tabindex="-1">
|
||||
<h2 id="reset-title">⚠ 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>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" id="modal-cancel">Cancel</button>
|
||||
@@ -335,8 +397,8 @@
|
||||
|
||||
<!-- Controllers management modal -->
|
||||
<div class="modal-overlay" id="controllers-modal">
|
||||
<div class="modal">
|
||||
<h2>Controllers</h2>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="controllers-title" tabindex="-1">
|
||||
<h2 id="controllers-title">Controllers</h2>
|
||||
<p>Each controller is a UniFi Access instance reachable from this server.
|
||||
Adding one will register a webhook on that controller automatically.</p>
|
||||
|
||||
@@ -379,8 +441,8 @@
|
||||
|
||||
<!-- Merge picker modal -->
|
||||
<div class="modal-overlay" id="merge-modal">
|
||||
<div class="modal">
|
||||
<h2>Merge with another actor</h2>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="merge-title" tabindex="-1">
|
||||
<h2 id="merge-title">Merge with another actor</h2>
|
||||
<p>Pick the other badge identity for <strong id="merge-source-name"></strong>.
|
||||
Names are matched fuzzily; type to narrow.</p>
|
||||
|
||||
@@ -406,8 +468,8 @@
|
||||
|
||||
<!-- People management modal -->
|
||||
<div class="modal-overlay" id="people-modal">
|
||||
<div class="modal">
|
||||
<h2>People</h2>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="people-title" tabindex="-1">
|
||||
<h2 id="people-title">People</h2>
|
||||
<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>
|
||||
|
||||
@@ -429,8 +491,8 @@
|
||||
|
||||
<!-- Filtered tenants modal -->
|
||||
<div class="modal-overlay" id="filtered-modal">
|
||||
<div class="modal">
|
||||
<h2>Filtered Tenants</h2>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="filtered-title" tabindex="-1">
|
||||
<h2 id="filtered-title">Filtered Tenants</h2>
|
||||
<p>These actors are hidden from the attendance table. Their badge events are
|
||||
still recorded — unhide to bring them back.</p>
|
||||
|
||||
@@ -446,8 +508,8 @@
|
||||
|
||||
<!-- Report modal -->
|
||||
<div class="modal-overlay" id="report-modal">
|
||||
<div class="modal wide">
|
||||
<h2>First Badge-In Report</h2>
|
||||
<div class="modal wide" role="dialog" aria-modal="true" aria-labelledby="report-title" tabindex="-1">
|
||||
<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
|
||||
report, or several for a group. Days with no badge-in show as <strong>absent</strong>.</p>
|
||||
|
||||
@@ -490,10 +552,45 @@
|
||||
</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>
|
||||
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) {
|
||||
const t = document.getElementById('toast');
|
||||
@@ -504,12 +601,135 @@
|
||||
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() {
|
||||
const sel = document.getElementById('controller-filter');
|
||||
const prev = sel.value;
|
||||
try {
|
||||
const res = await fetch('/api/controllers');
|
||||
const items = await res.json();
|
||||
const items = await apiFetch('/api/controllers');
|
||||
sel.innerHTML = '<option value="">All controllers</option>' +
|
||||
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;
|
||||
@@ -530,10 +750,9 @@
|
||||
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch('/api/first-badge-status?' + params.toString());
|
||||
data = await res.json();
|
||||
} catch {
|
||||
showToast('Could not load data', true);
|
||||
data = await apiFetch('/api/first-badge-status?' + params.toString());
|
||||
} catch (e) {
|
||||
showToast(`Could not load data: ${e.message}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -541,7 +760,7 @@
|
||||
tbody.innerHTML = '';
|
||||
|
||||
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 => {
|
||||
document.getElementById(id).textContent = '0';
|
||||
});
|
||||
@@ -556,15 +775,18 @@
|
||||
|
||||
const numTd = document.createElement('td');
|
||||
numTd.className = 'muted-cell';
|
||||
numTd.dataset.label = '#';
|
||||
numTd.textContent = i + 1;
|
||||
tr.appendChild(numTd);
|
||||
|
||||
const nameTd = document.createElement('td');
|
||||
nameTd.className = 'name-cell';
|
||||
nameTd.dataset.label = 'Name';
|
||||
nameTd.textContent = row.name;
|
||||
tr.appendChild(nameTd);
|
||||
|
||||
const sourceTd = document.createElement('td');
|
||||
sourceTd.dataset.label = 'Source';
|
||||
const sourceWrap = document.createElement('span');
|
||||
sourceWrap.className = 'source-list';
|
||||
const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—'];
|
||||
@@ -585,10 +807,12 @@
|
||||
|
||||
const firstTd = document.createElement('td');
|
||||
firstTd.className = 'time-first';
|
||||
firstTd.dataset.label = 'First Badge In';
|
||||
firstTd.textContent = row.first_ts || '—';
|
||||
tr.appendChild(firstTd);
|
||||
|
||||
const latestTd = document.createElement('td');
|
||||
latestTd.dataset.label = 'Latest Badge In';
|
||||
if (!row.latest_ts) {
|
||||
latestTd.className = 'same-badge';
|
||||
latestTd.textContent = '— same';
|
||||
@@ -600,11 +824,13 @@
|
||||
|
||||
const idTd = document.createElement('td');
|
||||
idTd.className = 'muted-cell';
|
||||
idTd.dataset.label = 'Actor ID';
|
||||
idTd.textContent = row.actor_id ? row.actor_id.slice(0, 8) + '...' : '—';
|
||||
tr.appendChild(idTd);
|
||||
|
||||
const statusTd = document.createElement('td');
|
||||
statusTd.className = 'align-center';
|
||||
statusTd.dataset.label = 'Status';
|
||||
const statusChip = document.createElement('div');
|
||||
const isOnTime = row.status === 'ON TIME';
|
||||
statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
|
||||
@@ -614,6 +840,7 @@
|
||||
|
||||
const actionTd = document.createElement('td');
|
||||
actionTd.className = 'align-center';
|
||||
actionTd.dataset.label = 'Actions';
|
||||
const actionGroup = document.createElement('div');
|
||||
actionGroup.className = 'row-action-group';
|
||||
|
||||
@@ -653,28 +880,26 @@
|
||||
showToast('Missing controller or actor id', true);
|
||||
return false;
|
||||
}
|
||||
const r = await fetch(`/api/users/${controllerId}/${actorId}`, {
|
||||
try {
|
||||
await apiFetch(`/api/users/${controllerId}/${actorId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filtered }),
|
||||
json: { filtered },
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
showToast(`Failed: ${j.error || r.status}`, true);
|
||||
} catch (e) {
|
||||
showToast(`Failed: ${e.message}`, true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function setPersonFiltered(personId, filtered) {
|
||||
const r = await fetch(`/api/persons/${personId}`, {
|
||||
try {
|
||||
await apiFetch(`/api/persons/${personId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filtered }),
|
||||
json: { filtered },
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
showToast(`Failed: ${j.error || r.status}`, true);
|
||||
} catch (e) {
|
||||
showToast(`Failed: ${e.message}`, true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -703,7 +928,7 @@
|
||||
if (mergeBtn) {
|
||||
if (mergeBtn.dataset.personId) {
|
||||
// Already merged — open People modal scrolled to this person
|
||||
document.getElementById('people-modal').classList.add('open');
|
||||
openModal('people-modal', '#people-close');
|
||||
await renderSuggestions();
|
||||
await renderPeople(mergeBtn.dataset.personId);
|
||||
} else {
|
||||
@@ -721,11 +946,11 @@
|
||||
const orig = btn.innerHTML;
|
||||
btn.textContent = 'Syncing…'; btn.disabled = true;
|
||||
try {
|
||||
await fetch('/api/sync-users');
|
||||
await apiFetch('/api/sync-users');
|
||||
showToast('User list synced from all controllers');
|
||||
await loadData();
|
||||
} catch {
|
||||
showToast('Sync failed — check server logs', true);
|
||||
} catch (e) {
|
||||
showToast(`Sync failed: ${e.message}`, true);
|
||||
} finally {
|
||||
btn.innerHTML = orig; btn.disabled = false;
|
||||
}
|
||||
@@ -735,29 +960,28 @@
|
||||
document.getElementById('reset-btn').addEventListener('click', () => {
|
||||
const date = document.getElementById('date').value || isoToday();
|
||||
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('reset-modal').classList.remove('open');
|
||||
closeModal('reset-modal');
|
||||
});
|
||||
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 controllerId = document.getElementById('controller-filter').value;
|
||||
const params = new URLSearchParams({ date });
|
||||
if (controllerId) params.set('controller_id', controllerId);
|
||||
try {
|
||||
const res = await fetch('/api/reset-day?' + params.toString(), { method: 'DELETE' });
|
||||
const json = await res.json();
|
||||
const json = await apiFetch('/api/reset-day?' + params.toString(), { method: 'DELETE' });
|
||||
showToast(`Reset complete — ${json.deleted} record(s) deleted for ${date}`);
|
||||
await loadData();
|
||||
} catch {
|
||||
showToast('Reset failed — check server logs', true);
|
||||
} catch (e) {
|
||||
showToast(`Reset failed: ${e.message}`, true);
|
||||
}
|
||||
});
|
||||
document.getElementById('reset-modal').addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('reset-modal'))
|
||||
document.getElementById('reset-modal').classList.remove('open');
|
||||
closeModal('reset-modal');
|
||||
});
|
||||
|
||||
// -------- Controllers modal --------
|
||||
@@ -779,8 +1003,7 @@
|
||||
|
||||
let items = [];
|
||||
try {
|
||||
const res = await fetch('/api/controllers');
|
||||
items = await res.json();
|
||||
items = await apiFetch('/api/controllers');
|
||||
} catch {
|
||||
list.innerHTML = '<div class="empty-state">Failed to load controllers.</div>';
|
||||
return;
|
||||
@@ -817,17 +1040,17 @@
|
||||
document.getElementById('open-controllers-btn').addEventListener('click', async () => {
|
||||
document.getElementById('base-url-hint').textContent = window.location.origin;
|
||||
document.getElementById('add-error').textContent = '';
|
||||
document.getElementById('controllers-modal').classList.add('open');
|
||||
openModal('controllers-modal', '#controllers-close');
|
||||
await renderControllers();
|
||||
});
|
||||
|
||||
document.getElementById('controllers-close').addEventListener('click', () => {
|
||||
document.getElementById('controllers-modal').classList.remove('open');
|
||||
closeModal('controllers-modal');
|
||||
});
|
||||
|
||||
document.getElementById('controllers-modal').addEventListener('click', e => {
|
||||
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 => {
|
||||
@@ -838,37 +1061,34 @@
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (act === 'test') {
|
||||
const r = await fetch(`/api/controllers/${id}/test`, { method: 'POST' });
|
||||
const j = await r.json();
|
||||
const j = await apiFetch(`/api/controllers/${id}/test`, { method: 'POST' });
|
||||
showToast(j.ok
|
||||
? `Connected — ${j.user_count} users on controller`
|
||||
: `Test failed: ${j.message}`,
|
||||
!j.ok);
|
||||
} else if (act === 'sync') {
|
||||
const r = await fetch(`/api/controllers/${id}/sync`, { method: 'POST' });
|
||||
const j = await r.json();
|
||||
const j = await apiFetch(`/api/controllers/${id}/sync`, { method: 'POST' });
|
||||
showToast(`Synced ${j.synced} users`);
|
||||
await renderControllers();
|
||||
await loadData();
|
||||
} else if (act === 'toggle') {
|
||||
const isEnabling = btn.textContent.trim().toLowerCase() === 'enable';
|
||||
await fetch(`/api/controllers/${id}`, {
|
||||
await apiFetch(`/api/controllers/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: isEnabling }),
|
||||
json: { enabled: isEnabling },
|
||||
});
|
||||
await renderControllers();
|
||||
await loadControllerList();
|
||||
} else if (act === 'delete') {
|
||||
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;
|
||||
const r = await fetch(`/api/controllers/${id}`, { method: 'DELETE' });
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
showToast(`Remove failed: ${j.error || r.status}`, true);
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/api/controllers/${id}`, { method: 'DELETE' });
|
||||
showToast(`Removed ${name}`);
|
||||
await renderControllers();
|
||||
await loadControllerList();
|
||||
@@ -900,17 +1120,10 @@
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Adding…';
|
||||
try {
|
||||
const r = await fetch('/api/controllers', {
|
||||
const j = await apiFetch('/api/controllers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
json: 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-host').value = '';
|
||||
document.getElementById('add-token').value = '';
|
||||
@@ -919,7 +1132,9 @@
|
||||
await loadControllerList();
|
||||
await loadData();
|
||||
} 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 {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Add Controller';
|
||||
@@ -932,8 +1147,7 @@
|
||||
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
||||
let items = [];
|
||||
try {
|
||||
const res = await fetch('/api/users?filtered=1');
|
||||
items = await res.json();
|
||||
items = await apiFetch('/api/users?filtered=1');
|
||||
} catch {
|
||||
list.innerHTML = '<div class="empty-state">Failed to load filtered tenants.</div>';
|
||||
return;
|
||||
@@ -963,15 +1177,15 @@
|
||||
}
|
||||
|
||||
document.getElementById('open-filtered-btn').addEventListener('click', async () => {
|
||||
document.getElementById('filtered-modal').classList.add('open');
|
||||
openModal('filtered-modal', '#filtered-close');
|
||||
await renderFiltered();
|
||||
});
|
||||
document.getElementById('filtered-close').addEventListener('click', () => {
|
||||
document.getElementById('filtered-modal').classList.remove('open');
|
||||
closeModal('filtered-modal');
|
||||
});
|
||||
document.getElementById('filtered-modal').addEventListener('click', e => {
|
||||
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 => {
|
||||
const btn = e.target.closest('button[data-act="unhide"]');
|
||||
@@ -1042,19 +1256,17 @@
|
||||
document.getElementById('merge-display-name').value = source.name;
|
||||
document.getElementById('merge-search').value = '';
|
||||
document.getElementById('merge-error').textContent = '';
|
||||
document.getElementById('merge-modal').classList.add('open');
|
||||
openModal('merge-modal', '#merge-search');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/users');
|
||||
mergeCandidates = await res.json();
|
||||
mergeCandidates = await apiFetch('/api/users');
|
||||
} catch {
|
||||
mergeCandidates = [];
|
||||
}
|
||||
// Only show actors not already in a person, by querying the persons API
|
||||
let merged = [];
|
||||
try {
|
||||
const res = await fetch('/api/persons');
|
||||
const persons = await res.json();
|
||||
const persons = await apiFetch('/api/persons');
|
||||
merged = persons.flatMap(p => p.members.map(m => `${m.controller_id}|${m.actor_id}`));
|
||||
} catch {}
|
||||
const mergedSet = new Set(merged);
|
||||
@@ -1074,11 +1286,11 @@
|
||||
renderMergeCandidates();
|
||||
});
|
||||
document.getElementById('merge-cancel').addEventListener('click', () => {
|
||||
document.getElementById('merge-modal').classList.remove('open');
|
||||
closeModal('merge-modal');
|
||||
});
|
||||
document.getElementById('merge-modal').addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('merge-modal'))
|
||||
document.getElementById('merge-modal').classList.remove('open');
|
||||
closeModal('merge-modal');
|
||||
});
|
||||
document.getElementById('merge-confirm').addEventListener('click', async () => {
|
||||
const err = document.getElementById('merge-error');
|
||||
@@ -1095,25 +1307,21 @@
|
||||
const btn = document.getElementById('merge-confirm');
|
||||
btn.disabled = true; btn.textContent = 'Merging…';
|
||||
try {
|
||||
const r = await fetch('/api/persons', {
|
||||
await apiFetch('/api/persons', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
display_name: displayName,
|
||||
members: [
|
||||
{ controller_id: mergeSource.controllerId, actor_id: mergeSource.actorId },
|
||||
{ controller_id: mergeSelected.controllerId, actor_id: mergeSelected.actorId },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
err.textContent = j.error || `Failed (${r.status})`;
|
||||
return;
|
||||
}
|
||||
document.getElementById('merge-modal').classList.remove('open');
|
||||
closeModal('merge-modal');
|
||||
showToast(`Merged into "${displayName}"`);
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
err.textContent = e.message || 'Merge failed';
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Merge';
|
||||
}
|
||||
@@ -1125,8 +1333,7 @@
|
||||
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
||||
let items = [];
|
||||
try {
|
||||
const res = await fetch('/api/persons/suggestions');
|
||||
items = await res.json();
|
||||
items = await apiFetch('/api/persons/suggestions');
|
||||
} catch {
|
||||
list.innerHTML = '<div class="empty-state">Failed to load suggestions.</div>';
|
||||
return;
|
||||
@@ -1157,8 +1364,7 @@
|
||||
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
||||
let items = [];
|
||||
try {
|
||||
const res = await fetch('/api/persons');
|
||||
items = await res.json();
|
||||
items = await apiFetch('/api/persons');
|
||||
} catch {
|
||||
list.innerHTML = '<div class="empty-state">Failed to load people.</div>';
|
||||
return;
|
||||
@@ -1205,16 +1411,16 @@
|
||||
}
|
||||
|
||||
document.getElementById('open-people-btn').addEventListener('click', async () => {
|
||||
document.getElementById('people-modal').classList.add('open');
|
||||
openModal('people-modal', '#people-close');
|
||||
await renderSuggestions();
|
||||
await renderPeople();
|
||||
});
|
||||
document.getElementById('people-close').addEventListener('click', () => {
|
||||
document.getElementById('people-modal').classList.remove('open');
|
||||
closeModal('people-modal');
|
||||
});
|
||||
document.getElementById('people-modal').addEventListener('click', e => {
|
||||
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 => {
|
||||
@@ -1225,25 +1431,21 @@
|
||||
try { payload = JSON.parse(btn.dataset.payload); }
|
||||
catch { btn.disabled = false; return; }
|
||||
try {
|
||||
const r = await fetch('/api/persons', {
|
||||
await apiFetch('/api/persons', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
display_name: payload.display_name,
|
||||
members: payload.members.map(m => ({
|
||||
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}"`);
|
||||
await renderSuggestions();
|
||||
await renderPeople();
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
showToast(`Merge failed: ${e.message}`, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
@@ -1256,37 +1458,50 @@
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (act === 'rename') {
|
||||
const name = prompt('New display name:', btn.dataset.name);
|
||||
if (!name || !name.trim()) return;
|
||||
const r = await fetch(`/api/persons/${btn.dataset.personId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name.trim() }),
|
||||
const name = await askText({
|
||||
title: 'Rename Person',
|
||||
label: 'Display name',
|
||||
value: btn.dataset.name,
|
||||
confirmText: 'Rename',
|
||||
});
|
||||
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()}"`);
|
||||
await renderPeople();
|
||||
await loadData();
|
||||
} 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;
|
||||
const r = await fetch(`/api/persons/${btn.dataset.personId}`, { method: 'DELETE' });
|
||||
if (!r.ok) { showToast('Dissolve failed', true); return; }
|
||||
await apiFetch(`/api/persons/${btn.dataset.personId}`, { method: 'DELETE' });
|
||||
showToast(`Dissolved "${btn.dataset.name}"`);
|
||||
await renderSuggestions();
|
||||
await renderPeople();
|
||||
await loadData();
|
||||
} 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;
|
||||
const url = `/api/persons/${btn.dataset.personId}/members/${btn.dataset.controllerId}/${btn.dataset.actorId}`;
|
||||
const r = await fetch(url, { method: 'DELETE' });
|
||||
if (!r.ok) { showToast('Split failed', true); return; }
|
||||
await apiFetch(url, { method: 'DELETE' });
|
||||
showToast(`Split off ${btn.dataset.name}`);
|
||||
await renderSuggestions();
|
||||
await renderPeople();
|
||||
await loadData();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Action failed: ${e.message}`, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
@@ -1341,11 +1556,10 @@
|
||||
if (cid) p.set('controller_id', cid);
|
||||
if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1');
|
||||
try {
|
||||
const res = await fetch('/api/report/subjects?' + p.toString());
|
||||
reportSubjects = await res.json();
|
||||
} catch {
|
||||
reportSubjects = await apiFetch('/api/report/subjects?' + p.toString());
|
||||
} catch (e) {
|
||||
reportSubjects = [];
|
||||
showToast('Could not load users', true);
|
||||
showToast(`Could not load users: ${e.message}`, true);
|
||||
}
|
||||
renderReportSubjects();
|
||||
}
|
||||
@@ -1353,14 +1567,13 @@
|
||||
async function runReport() {
|
||||
const errEl = document.getElementById('report-error');
|
||||
errEl.textContent = '';
|
||||
const res = await fetch('/api/report?' + reportParams().toString());
|
||||
let data;
|
||||
try { data = await res.json(); } catch { data = null; }
|
||||
if (!res.ok || !data) {
|
||||
errEl.textContent = (data && data.error) || 'Report failed';
|
||||
try {
|
||||
const data = await apiFetch('/api/report?' + reportParams().toString());
|
||||
renderPivot(data);
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Report failed';
|
||||
return;
|
||||
}
|
||||
renderPivot(data);
|
||||
}
|
||||
|
||||
function renderPivot(data) {
|
||||
@@ -1413,7 +1626,7 @@
|
||||
function openReportModal() {
|
||||
const today = isoToday();
|
||||
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-cutoff').value =
|
||||
document.getElementById('cutoff').value || '09:00';
|
||||
@@ -1423,16 +1636,16 @@
|
||||
document.getElementById('report-result').innerHTML = '';
|
||||
reportSelected.clear();
|
||||
updateReportSelCount();
|
||||
document.getElementById('report-modal').classList.add('open');
|
||||
openModal('report-modal', '#report-search');
|
||||
loadReportSubjects();
|
||||
}
|
||||
|
||||
document.getElementById('open-report-btn').addEventListener('click', openReportModal);
|
||||
document.getElementById('report-close').addEventListener('click', () => {
|
||||
document.getElementById('report-modal').classList.remove('open');
|
||||
closeModal('report-modal');
|
||||
});
|
||||
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-subjects').addEventListener('change', e => {
|
||||
|
||||
Reference in New Issue
Block a user