f85acab646
Build and Push Docker Image / build (push) Successful in 6s
Rewrite the stylesheet: soft small radii instead of 999px pills, quiet flat buttons with per-function color accents and a single gold primary, layered surfaces with hairline borders, reduced uppercase typography, tabular numerals for times, eased hover/focus/press transitions, animated modals, pulsing live indicators, and a reduced-motion fallback. Remove the header badge; fix uppercase names in the report picker and the label underline bleed in the mobile card layout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2186 lines
91 KiB
HTML
2186 lines
91 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>UniFi Access Attendance</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--bg: #0b0b10;
|
|
--surface: #14141a;
|
|
--surface-2: #1b1b23;
|
|
--surface-3: #24242e;
|
|
--modal-bg: #16161d;
|
|
--border: rgba(255,255,255,0.07);
|
|
--border-strong: rgba(255,255,255,0.13);
|
|
--gold: #d9b64a;
|
|
--gold-soft: #b89630;
|
|
--gold-text: #e8cf7f;
|
|
--text: #ececf1;
|
|
--muted: #9092a0;
|
|
--danger: #f0555d;
|
|
--success: #34d399;
|
|
--warn: #f5a524;
|
|
--blue: #6cb0f5;
|
|
--violet: #b9a0f2;
|
|
--r-sm: 7px;
|
|
--r-md: 10px;
|
|
--r-lg: 14px;
|
|
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
}
|
|
html { color-scheme: dark; }
|
|
body {
|
|
background:
|
|
radial-gradient(1100px 480px at 50% -12%, rgba(217,182,74,0.055), transparent 60%),
|
|
var(--bg);
|
|
color: var(--text);
|
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
min-height: 100vh; display: flex; justify-content: center; padding: 40px 20px;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
.app-shell { width: 100%; max-width: 1280px; }
|
|
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; gap: 16px; }
|
|
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
|
.health-pill {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: var(--r-sm); padding: 6px 12px;
|
|
color: var(--muted); font-size: 0.78rem;
|
|
}
|
|
.title-block { display: flex; flex-direction: column; gap: 4px; }
|
|
h1 { font-size: 1.45rem; font-weight: 650; letter-spacing: -0.01em; color: var(--text); display: flex; align-items: center; }
|
|
h1::before {
|
|
content: ''; width: 11px; height: 11px; border-radius: 3px; flex: none;
|
|
background: linear-gradient(135deg, var(--gold), var(--gold-soft));
|
|
margin-right: 10px;
|
|
}
|
|
.subtitle { font-size: 0.88rem; color: var(--muted); }
|
|
.controls {
|
|
display: flex; flex-wrap: wrap; gap: 12px 14px; align-items: flex-end;
|
|
background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: var(--r-lg); padding: 14px 16px; margin-bottom: 16px;
|
|
}
|
|
.control-group { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
.control-group:has(> label:not(.show-filtered-toggle)) {
|
|
flex-direction: column; align-items: flex-start; gap: 5px;
|
|
}
|
|
.spacer { flex: 1; }
|
|
label {
|
|
font-size: 0.68rem; font-weight: 600; letter-spacing: 0.07em;
|
|
text-transform: uppercase; color: var(--muted);
|
|
}
|
|
input, select {
|
|
background: var(--surface-2); border: 1px solid var(--border-strong);
|
|
border-radius: var(--r-sm); padding: 8px 12px; font-size: 0.9rem;
|
|
font-family: inherit; color: var(--text); outline: none; min-width: 130px;
|
|
transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease), background 0.15s var(--ease);
|
|
}
|
|
input:hover, select:hover { border-color: rgba(255,255,255,0.22); }
|
|
input:focus, select:focus {
|
|
border-color: rgba(217,182,74,0.55);
|
|
box-shadow: 0 0 0 3px rgba(217,182,74,0.12);
|
|
}
|
|
input[type="checkbox"] {
|
|
accent-color: var(--gold); width: 15px; height: 15px;
|
|
min-width: 0; padding: 0; cursor: pointer;
|
|
}
|
|
button {
|
|
border-radius: var(--r-sm); border: 1px solid var(--border-strong);
|
|
background: var(--surface-2); color: var(--text);
|
|
padding: 8px 14px; font-size: 0.85rem; font-weight: 500;
|
|
font-family: inherit; cursor: pointer; white-space: nowrap;
|
|
transition: background 0.15s var(--ease), border-color 0.15s var(--ease),
|
|
color 0.15s var(--ease), transform 0.1s var(--ease), box-shadow 0.15s var(--ease);
|
|
}
|
|
button:hover { background: var(--surface-3); border-color: rgba(255,255,255,0.22); }
|
|
button:active { transform: scale(0.98); }
|
|
button:disabled { opacity: 0.45; cursor: default; transform: none; }
|
|
button:focus-visible, input:focus-visible, select:focus-visible {
|
|
outline: 2px solid rgba(217,182,74,0.55); outline-offset: 2px;
|
|
}
|
|
#refresh-btn, #add-controller-btn, #merge-confirm, #text-ok, #report-run-btn {
|
|
background: linear-gradient(180deg, rgba(217,182,74,0.22), rgba(217,182,74,0.10));
|
|
border-color: rgba(217,182,74,0.45); color: var(--gold-text);
|
|
}
|
|
#refresh-btn:hover, #add-controller-btn:hover, #merge-confirm:hover, #text-ok:hover, #report-run-btn:hover {
|
|
background: linear-gradient(180deg, rgba(217,182,74,0.3), rgba(217,182,74,0.14));
|
|
border-color: rgba(217,182,74,0.65);
|
|
}
|
|
.sync-btn, .people-btn { color: var(--blue); }
|
|
.controllers-btn { color: var(--violet); }
|
|
.filtered-btn { color: var(--warn); }
|
|
.report-btn { color: var(--success); }
|
|
.reset-btn { color: var(--danger); }
|
|
.sync-btn:hover, .people-btn:hover, .controllers-btn:hover,
|
|
.filtered-btn:hover, .report-btn:hover, .reset-btn:hover {
|
|
border-color: color-mix(in srgb, currentColor 45%, transparent);
|
|
background: color-mix(in srgb, currentColor 9%, var(--surface-2));
|
|
}
|
|
.small-btn { padding: 5px 10px; font-size: 0.75rem; }
|
|
.summary-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-size: 0.85rem; align-items: center; }
|
|
.summary-pill {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: var(--r-sm); padding: 7px 14px; color: var(--muted);
|
|
}
|
|
button.summary-pill {
|
|
cursor: pointer; font-size: 0.85rem; font-weight: 500;
|
|
transition: border-color 0.15s var(--ease), background 0.15s var(--ease), color 0.15s var(--ease);
|
|
}
|
|
button.summary-pill span { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
button.summary-pill:hover { border-color: var(--border-strong); background: var(--surface-2); transform: none; box-shadow: none; }
|
|
button.summary-pill.active { border-color: rgba(217,182,74,0.5); color: var(--text); background: rgba(217,182,74,0.08); }
|
|
.live-indicator { font-size: 0.78rem; }
|
|
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
|
.dot.on { background: var(--success); }
|
|
.dot.off { background: var(--danger); }
|
|
.dot.all { background: var(--gold); }
|
|
.dot.stale { background: var(--warn); }
|
|
.dot.idle { background: var(--muted); opacity: 0.5; }
|
|
.health-pill .dot.on, .live-indicator .dot.on { animation: pulse-dot 2.4s var(--ease) infinite; }
|
|
@keyframes pulse-dot {
|
|
0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.45); }
|
|
70% { box-shadow: 0 0 0 6px rgba(52,211,153,0); }
|
|
100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); }
|
|
}
|
|
.table-card {
|
|
background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: var(--r-lg); overflow-x: auto; overflow-y: hidden;
|
|
}
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
|
thead { background: var(--surface-2); }
|
|
th, td { padding: 11px 16px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
th { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
|
th.sortable { cursor: pointer; user-select: none; transition: color 0.15s var(--ease); }
|
|
th.sortable:hover { color: var(--text); }
|
|
th.sortable .sort-arrow { opacity: 0; margin-left: 4px; font-size: 0.65rem; transition: opacity 0.15s var(--ease); }
|
|
th.sortable.sorted-asc .sort-arrow,
|
|
th.sortable.sorted-desc .sort-arrow { opacity: 1; color: var(--gold); }
|
|
tbody tr { transition: background 0.12s var(--ease); }
|
|
tbody tr:last-child td { border-bottom: none; }
|
|
tbody tr:hover { background: rgba(255,255,255,0.025); }
|
|
.name-cell { font-weight: 500; color: var(--text); }
|
|
.muted-cell { color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; }
|
|
.source-chip {
|
|
display: inline-block; padding: 2px 8px; border-radius: 5px;
|
|
font-size: 0.74rem; color: var(--blue);
|
|
background: rgba(108,176,245,0.09); border: 1px solid rgba(108,176,245,0.22);
|
|
}
|
|
.align-center { text-align: center; }
|
|
.time-first { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
.time-first.clickable {
|
|
cursor: pointer; text-decoration: underline dotted rgba(217,182,74,0.5);
|
|
text-underline-offset: 3px; transition: color 0.15s var(--ease);
|
|
}
|
|
.time-first.clickable:hover { color: var(--gold-text); }
|
|
.time-latest { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
|
|
.times-list { display: flex; flex-direction: column; gap: 6px; max-height: 360px; overflow-y: auto; margin: 10px 0 4px; }
|
|
.time-entry {
|
|
display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center;
|
|
padding: 9px 14px; background: var(--surface-2);
|
|
border: 1px solid var(--border); border-radius: var(--r-sm);
|
|
}
|
|
.time-entry .t-idx { color: var(--muted); font-size: 0.78rem; min-width: 1.4em; }
|
|
.time-entry .t-time { font-family: ui-monospace, monospace; font-size: 0.95rem; color: var(--text); }
|
|
.time-entry.is-first .t-time { color: var(--gold-text); font-weight: 600; }
|
|
.time-entry .t-tag {
|
|
font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase;
|
|
color: var(--gold-text); border: 1px solid rgba(217,182,74,0.35);
|
|
border-radius: 4px; padding: 1px 6px; margin-left: 6px;
|
|
}
|
|
.same-badge { color: var(--muted); opacity: 0.6; font-size: 0.82rem; font-style: italic; }
|
|
.status-chip {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
min-width: 84px; padding: 4px 10px; border-radius: var(--r-sm);
|
|
font-size: 0.72rem; font-weight: 600; letter-spacing: 0.05em;
|
|
}
|
|
.status-on { background: rgba(52,211,153,0.1); color: #7ce8bd; border: 1px solid rgba(52,211,153,0.3); }
|
|
.status-off { background: rgba(240,85,93,0.1); color: #ff9ba0; border: 1px solid rgba(240,85,93,0.35); }
|
|
.chip-dot { width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; }
|
|
.empty-state { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; }
|
|
.empty-state span { color: var(--gold-text); }
|
|
.onboard-cta { padding: 36px 16px; text-align: center; }
|
|
.onboard-cta h3 { color: var(--text); font-size: 1.05rem; font-weight: 650; margin-bottom: 8px; }
|
|
.onboard-cta p { color: var(--muted); font-size: 0.88rem; margin-bottom: 16px; line-height: 1.5; }
|
|
|
|
tr.row-filtered td { opacity: 0.45; }
|
|
tr.row-filtered .name-cell::after {
|
|
content: 'FILTERED'; margin-left: 8px; font-size: 0.62rem; letter-spacing: 0.06em;
|
|
color: var(--warn); border: 1px solid rgba(245,165,36,0.4);
|
|
padding: 1px 6px; border-radius: 4px; vertical-align: middle;
|
|
}
|
|
.row-action-btn {
|
|
padding: 4px 10px; font-size: 0.72rem; font-weight: 500;
|
|
border-radius: 6px; border: 1px solid var(--border-strong);
|
|
background: transparent; color: var(--muted); cursor: pointer;
|
|
}
|
|
.row-action-btn:hover { color: var(--text); background: var(--surface-2); border-color: rgba(255,255,255,0.22); }
|
|
.row-action-btn.unhide { color: var(--success); border-color: rgba(52,211,153,0.35); }
|
|
.row-action-btn.unhide:hover { background: rgba(52,211,153,0.08); color: #7ce8bd; }
|
|
|
|
.show-filtered-toggle {
|
|
display: inline-flex; align-items: center; gap: 7px;
|
|
font-size: 0.78rem; font-weight: 500; color: var(--muted);
|
|
letter-spacing: normal; text-transform: none; cursor: pointer;
|
|
padding-bottom: 9px; transition: color 0.15s var(--ease);
|
|
}
|
|
.show-filtered-toggle:hover { color: var(--text); }
|
|
.show-filtered-toggle input { min-width: 0; width: auto; margin: 0; }
|
|
|
|
/* Report modal */
|
|
.modal.wide { max-width: 1000px; }
|
|
.report-controls {
|
|
display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; margin-bottom: 14px;
|
|
}
|
|
.report-controls .control-group { display: flex; flex-direction: column; align-items: flex-start; gap: 5px; }
|
|
.report-controls input { min-width: 130px; }
|
|
.subject-picker { display: flex; flex-direction: column; gap: 4px;
|
|
max-height: 220px; overflow-y: auto; margin: 6px 0 4px;
|
|
border: 1px solid var(--border); border-radius: var(--r-sm); padding: 6px; }
|
|
.subject-row {
|
|
display: flex; align-items: center; gap: 10px; padding: 6px 8px;
|
|
border-radius: 6px; cursor: pointer; font-size: 0.85rem;
|
|
text-transform: none; letter-spacing: normal; font-weight: 500; color: var(--text);
|
|
transition: background 0.12s var(--ease);
|
|
}
|
|
.subject-row:hover { background: rgba(255,255,255,0.04); }
|
|
.subject-row input { width: auto; min-width: 0; margin: 0; }
|
|
.subject-row .s-sub { color: var(--muted); font-size: 0.72rem; margin-left: auto; }
|
|
.picker-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 6px;
|
|
font-size: 0.74rem; }
|
|
.picker-toolbar a { color: var(--blue); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
|
.picker-toolbar .count { color: var(--muted); margin-left: auto; }
|
|
|
|
.report-result { margin-top: 8px; overflow-x: auto; }
|
|
table.pivot { font-size: 0.82rem; }
|
|
table.pivot th, table.pivot td { padding: 8px 10px; white-space: nowrap; text-align: center; }
|
|
table.pivot th.name-col, table.pivot td.name-col {
|
|
text-align: left; position: sticky; left: 0; background: var(--modal-bg); z-index: 1;
|
|
font-weight: 500; color: var(--text);
|
|
}
|
|
table.pivot td.cell-late { color: #ff9ba0; }
|
|
table.pivot td.cell-on { color: #7ce8bd; }
|
|
table.pivot td.cell-absent { color: var(--muted); }
|
|
table.pivot th.weekend, table.pivot td.weekend { background: rgba(255,255,255,0.02); }
|
|
table.pivot th.total-col, table.pivot td.total-col {
|
|
border-left: 1px solid var(--border-strong);
|
|
font-variant-numeric: tabular-nums; font-size: 0.78rem;
|
|
}
|
|
table.pivot td.tot-late { color: #ff9ba0; }
|
|
table.pivot td.tot-absent { color: var(--warn); }
|
|
table.pivot tr.totals-row td { border-top: 1px solid var(--border-strong); font-weight: 600; color: var(--text); }
|
|
table.pivot tr.totals-row td.name-col { color: var(--gold-text); }
|
|
.report-summary { font-size: 0.78rem; color: var(--muted); margin: 10px 0; }
|
|
|
|
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
|
.merged-pill {
|
|
display: inline-block; margin-left: 8px; padding: 1px 7px;
|
|
font-size: 0.64rem; font-weight: 600; letter-spacing: 0.06em; border-radius: 5px;
|
|
color: var(--blue); border: 1px solid rgba(108,176,245,0.35);
|
|
background: rgba(108,176,245,0.08); vertical-align: middle;
|
|
}
|
|
.row-action-btn.merge { border-color: rgba(108,176,245,0.35); color: var(--blue); }
|
|
.row-action-btn.merge:hover { background: rgba(108,176,245,0.08); }
|
|
.row-action-group { display: inline-flex; gap: 6px; justify-content: center; }
|
|
|
|
.picker-list { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; margin-bottom: 12px; }
|
|
.picker-row {
|
|
display: grid; grid-template-columns: 1fr auto; gap: 10px;
|
|
padding: 8px 12px; background: var(--surface-2);
|
|
border: 1px solid var(--border); border-radius: var(--r-sm); align-items: center;
|
|
}
|
|
.picker-row .ctrl-name { font-size: 0.88rem; }
|
|
.picker-row .ctrl-sub { font-size: 0.74rem; }
|
|
.picker-search { width: 100%; margin-bottom: 10px; }
|
|
|
|
.suggestion-row {
|
|
padding: 10px 14px; background: rgba(108,176,245,0.06);
|
|
border: 1px solid rgba(108,176,245,0.2); border-radius: var(--r-sm);
|
|
display: grid; grid-template-columns: 1fr auto; gap: 10px;
|
|
align-items: center; margin-bottom: 8px;
|
|
}
|
|
.suggestion-row .ctrl-name { font-weight: 600; }
|
|
.suggestion-row .ctrl-sub { font-size: 0.76rem; color: var(--muted); }
|
|
.section-label {
|
|
font-size: 0.7rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase;
|
|
color: var(--muted); margin: 14px 0 8px;
|
|
}
|
|
|
|
.modal-overlay {
|
|
display: none; position: fixed; inset: 0; background: rgba(4,4,8,0.62);
|
|
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
|
|
z-index: 100; align-items: center; justify-content: center;
|
|
}
|
|
.modal-overlay.open { display: flex; animation: overlay-in 0.18s ease; }
|
|
.modal {
|
|
background: var(--modal-bg); border: 1px solid var(--border-strong);
|
|
border-radius: var(--r-lg); padding: 24px; max-width: 720px; width: 92%;
|
|
box-shadow: 0 24px 64px rgba(0,0,0,0.6); max-height: 90vh; overflow-y: auto;
|
|
}
|
|
.modal-overlay.open .modal { animation: modal-in 0.22s var(--ease); }
|
|
@keyframes overlay-in { from { opacity: 0; } }
|
|
@keyframes modal-in { from { opacity: 0; transform: translateY(10px) scale(0.985); } }
|
|
.modal.danger { border-color: rgba(240,85,93,0.4); max-width: 420px; text-align: center; }
|
|
.modal h2 { color: var(--text); font-size: 1.05rem; font-weight: 650; margin-bottom: 12px; }
|
|
.modal.danger h2 { color: var(--danger); }
|
|
.modal p { color: var(--muted); font-size: 0.88rem; margin-bottom: 16px; line-height: 1.55; }
|
|
.modal p strong { color: var(--text); }
|
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
|
|
.modal.danger .modal-actions { justify-content: center; }
|
|
.modal-cancel { background: transparent; border-color: var(--border-strong); color: var(--muted); }
|
|
.modal-cancel:hover { color: var(--text); background: var(--surface-2); }
|
|
.modal-confirm { background: rgba(240,85,93,0.14); border-color: rgba(240,85,93,0.5); color: #ff9ba0; }
|
|
.modal-confirm:hover { background: rgba(240,85,93,0.22); border-color: rgba(240,85,93,0.7); }
|
|
|
|
.ctrl-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; }
|
|
.ctrl-row {
|
|
display: grid; grid-template-columns: 1fr auto;
|
|
gap: 8px 16px; padding: 12px 14px;
|
|
background: rgba(255,255,255,0.025);
|
|
border: 1px solid var(--border); border-radius: var(--r-sm); align-items: center;
|
|
}
|
|
.ctrl-meta { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
.ctrl-name { font-weight: 600; color: var(--text); font-size: 0.92rem; }
|
|
.ctrl-name .disabled-tag { color: var(--muted); font-size: 0.7rem; margin-left: 6px; font-weight: 400; }
|
|
.ctrl-sub { font-size: 0.78rem; color: var(--muted); font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; }
|
|
.ctrl-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
|
.ctrl-actions button { padding: 5px 10px; font-size: 0.72rem; }
|
|
|
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin-bottom: 14px; }
|
|
.form-grid .full { grid-column: 1 / -1; }
|
|
.form-grid label { display: block; margin-bottom: 4px; }
|
|
.form-grid input { width: 100%; min-width: 0; }
|
|
.form-hint { font-size: 0.78rem; color: var(--muted); margin-top: -4px; margin-bottom: 10px; }
|
|
.form-error { color: var(--danger); font-size: 0.82rem; margin-bottom: 10px; min-height: 1.1em; }
|
|
|
|
.toast {
|
|
position: fixed; bottom: 24px; right: 24px; background: var(--surface-2);
|
|
border: 1px solid rgba(217,182,74,0.4); border-radius: var(--r-md); padding: 12px 16px;
|
|
font-size: 0.85rem; color: var(--text); opacity: 0; transform: translateY(12px);
|
|
transition: opacity 0.25s var(--ease), transform 0.25s var(--ease); pointer-events: none; z-index: 200;
|
|
max-width: 360px; box-shadow: 0 12px 32px rgba(0,0,0,0.5);
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
.toast.error { border-color: rgba(240,85,93,0.5); color: #ff9ba0; }
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after { animation: none !important; transition: none !important; }
|
|
}
|
|
|
|
@media (max-width: 800px) {
|
|
body { padding: 12px; align-items: flex-start; }
|
|
header { flex-direction: column; align-items: flex-start; }
|
|
h1 { font-size: 1.25rem; }
|
|
.subtitle { font-size: 0.82rem; }
|
|
.controls { flex-direction: column; align-items: stretch; padding: 12px; }
|
|
.control-group { flex-direction: column; align-items: stretch; gap: 6px; }
|
|
.control-group:has(> label:not(.show-filtered-toggle)) { align-items: stretch; }
|
|
input, select, button { width: 100%; }
|
|
.show-filtered-toggle { flex-direction: row; align-items: center; width: 100%; padding-bottom: 0; }
|
|
.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: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-md);
|
|
padding: 10px 12px;
|
|
}
|
|
.table-card tbody tr:hover { background: var(--surface-2); }
|
|
.table-card td {
|
|
display: grid;
|
|
grid-template-columns: minmax(96px, 35%) 1fr;
|
|
gap: 12px;
|
|
align-items: start;
|
|
padding: 9px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
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;
|
|
font-weight: 600;
|
|
letter-spacing: 0.07em;
|
|
text-transform: uppercase;
|
|
text-align: left;
|
|
}
|
|
.table-card .name-cell { font-size: 1rem; }
|
|
.table-card .time-first.clickable { text-decoration: none; }
|
|
.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: var(--r-md); }
|
|
.modal-actions { flex-direction: column-reverse; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-shell">
|
|
<header>
|
|
<div class="title-block">
|
|
<h1>Building Access</h1>
|
|
<div class="subtitle">Daily badge-in attendance — powered by UniFi Access</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<div class="health-pill" id="health-pill" title="Webhook ingestion health">
|
|
<span class="dot" id="health-dot"></span>
|
|
<span id="health-text">checking…</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="controls">
|
|
<div class="control-group">
|
|
<label for="date">Date</label>
|
|
<input type="date" id="date">
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="cutoff">Badged in by</label>
|
|
<input type="time" id="cutoff" value="09:00">
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="controller-filter">Controller</label>
|
|
<select id="controller-filter"><option value="">All</option></select>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="name-search">Search</label>
|
|
<input type="search" id="name-search" placeholder="Filter by name…" autocomplete="off">
|
|
</div>
|
|
<div class="control-group">
|
|
<label class="show-filtered-toggle" for="show-filtered">
|
|
<input type="checkbox" id="show-filtered"> Show filtered
|
|
</label>
|
|
</div>
|
|
<div class="control-group">
|
|
<label class="show-filtered-toggle" for="auto-refresh">
|
|
<input type="checkbox" id="auto-refresh" checked> Auto-refresh
|
|
</label>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<div class="control-group">
|
|
<button id="today-btn" class="small-btn">Today</button>
|
|
</div>
|
|
<div class="control-group">
|
|
<button id="refresh-btn">↻ Refresh</button>
|
|
<button class="sync-btn" id="sync-btn">↻ Sync Users</button>
|
|
<button class="filtered-btn" id="open-filtered-btn">🚫 Filtered</button>
|
|
<button class="report-btn" id="open-report-btn">📊 Report</button>
|
|
<button class="people-btn" id="open-people-btn">👥 People</button>
|
|
<button class="controllers-btn" id="open-controllers-btn">⚙ Controllers</button>
|
|
<button class="reset-btn" id="reset-btn">✕ Reset Day</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="summary-row">
|
|
<button class="summary-pill filter-pill" data-status="ON TIME"><div class="dot on"></div><span id="on-time-count">0</span> on time</button>
|
|
<button class="summary-pill filter-pill" data-status="LATE"><div class="dot off"></div><span id="late-count">0</span> late</button>
|
|
<button class="summary-pill filter-pill active" data-status=""><div class="dot all"></div><span id="total-count">0</span> total</button>
|
|
<div class="spacer"></div>
|
|
<div class="summary-pill live-indicator" id="live-indicator" title="Auto-refresh status">
|
|
<span class="dot" id="live-dot"></span>
|
|
<span id="live-text">—</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="table-card">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th class="sortable" data-sort="name">Name<span class="sort-arrow">▲</span></th>
|
|
<th>Source</th>
|
|
<th class="sortable" data-sort="first_ts">First Badge In<span class="sort-arrow">▲</span></th>
|
|
<th class="sortable" data-sort="latest_ts">Latest Badge In<span class="sort-arrow">▲</span></th>
|
|
<th>Actor ID</th>
|
|
<th class="align-center sortable" data-sort="status">Status<span class="sort-arrow">▲</span></th>
|
|
<th class="align-center">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="table-body">
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Reset confirmation modal -->
|
|
<div class="modal-overlay" id="reset-modal">
|
|
<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>
|
|
<button class="modal-confirm" id="modal-confirm">Yes, Reset</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controllers management modal -->
|
|
<div class="modal-overlay" id="controllers-modal">
|
|
<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>
|
|
|
|
<div class="ctrl-list" id="ctrl-list">
|
|
<div class="empty-state">No controllers configured.</div>
|
|
</div>
|
|
|
|
<h2 style="margin-top: 8px;">Add Controller</h2>
|
|
<div class="form-grid">
|
|
<div class="full">
|
|
<label for="add-name">Name</label>
|
|
<input type="text" id="add-name" placeholder="e.g. Main Office">
|
|
</div>
|
|
<div>
|
|
<label for="add-host">Host / IP</label>
|
|
<input type="text" id="add-host" placeholder="10.0.0.1">
|
|
</div>
|
|
<div>
|
|
<label for="add-port">Port</label>
|
|
<input type="number" id="add-port" value="12445">
|
|
</div>
|
|
<div class="full">
|
|
<label for="add-token">Developer API Token</label>
|
|
<input type="password" id="add-token" placeholder="paste token from UniFi Access">
|
|
</div>
|
|
</div>
|
|
<div class="form-hint">
|
|
The dashboard registers its webhook URL with the controller using
|
|
<span id="base-url-hint" style="color: var(--gold-soft);"></span>.
|
|
Set the <code>DASHBOARD_BASE_URL</code> env var if the controller can't reach that address.
|
|
</div>
|
|
<div class="form-error" id="add-error"></div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="modal-cancel" id="controllers-close">Close</button>
|
|
<button id="add-controller-btn">Add Controller</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Merge picker modal -->
|
|
<div class="modal-overlay" id="merge-modal">
|
|
<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>
|
|
|
|
<input type="text" id="merge-search" class="picker-search" placeholder="Search by name…">
|
|
<div class="picker-list" id="merge-candidates">
|
|
<div class="empty-state">Loading…</div>
|
|
</div>
|
|
|
|
<div class="form-grid">
|
|
<div class="full">
|
|
<label for="merge-display-name">Display name</label>
|
|
<input type="text" id="merge-display-name" placeholder="Will appear in the attendance table">
|
|
</div>
|
|
</div>
|
|
<div class="form-error" id="merge-error"></div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="modal-cancel" id="merge-cancel">Cancel</button>
|
|
<button id="merge-confirm">Merge</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- People management modal -->
|
|
<div class="modal-overlay" id="people-modal">
|
|
<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>
|
|
|
|
<div class="section-label">Suggested merges</div>
|
|
<div class="ctrl-list" id="suggestions-list">
|
|
<div class="empty-state">Loading…</div>
|
|
</div>
|
|
|
|
<div class="section-label">Merged people</div>
|
|
<div class="ctrl-list" id="people-list">
|
|
<div class="empty-state">Loading…</div>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="modal-cancel" id="people-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtered tenants modal -->
|
|
<div class="modal-overlay" id="filtered-modal">
|
|
<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>
|
|
|
|
<div class="ctrl-list" id="filtered-list">
|
|
<div class="empty-state">Loading…</div>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="modal-cancel" id="filtered-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Report modal -->
|
|
<div class="modal-overlay" id="report-modal">
|
|
<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>
|
|
|
|
<div class="report-controls">
|
|
<div class="control-group">
|
|
<label for="report-start">Start</label>
|
|
<input type="date" id="report-start">
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="report-end">End</label>
|
|
<input type="date" id="report-end">
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="report-cutoff">On-time cutoff</label>
|
|
<input type="time" id="report-cutoff" value="09:00">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-label">Users</div>
|
|
<input type="text" id="report-search" class="picker-search" placeholder="Search by name…">
|
|
<div class="picker-toolbar">
|
|
<a id="report-select-all">Select all</a>
|
|
<a id="report-select-none">Clear</a>
|
|
<span class="count" id="report-sel-count">0 selected</span>
|
|
</div>
|
|
<div class="subject-picker" id="report-subjects">
|
|
<div class="empty-state">Loading…</div>
|
|
</div>
|
|
|
|
<div class="form-error" id="report-error"></div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="modal-cancel" id="report-close">Close</button>
|
|
<button class="report-btn" id="report-csv-btn">⬇ Export CSV</button>
|
|
<button id="report-run-btn">Run Report</button>
|
|
</div>
|
|
|
|
<div class="report-summary" id="report-summary"></div>
|
|
<div class="report-result" id="report-result"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Badge-in times modal -->
|
|
<div class="modal-overlay" id="times-modal">
|
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="times-title" tabindex="-1">
|
|
<h2 id="times-title">Badge-In Times</h2>
|
|
<p id="times-subhead">Every badge-in for this person on the selected day.</p>
|
|
<div class="times-list" id="times-list">
|
|
<div class="empty-state">Loading…</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="modal-cancel" id="times-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</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(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');
|
|
t.textContent = msg;
|
|
t.classList.toggle('error', isError);
|
|
t.classList.add('show');
|
|
clearTimeout(t._timer);
|
|
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();
|
|
});
|
|
|
|
// ----- View state -----
|
|
let controllersCache = []; // last fetched /api/controllers
|
|
let currentRows = []; // last fetched /api/first-badge-status
|
|
let sortState = { key: 'first_ts', dir: 'asc' };
|
|
let statusFilter = ''; // '', 'ON TIME', 'LATE'
|
|
let lastLoadedAt = 0;
|
|
let loadInFlight = false;
|
|
let pendingController = ''; // controller id to select once dropdown is built
|
|
const AUTO_REFRESH_SEC = 45;
|
|
|
|
function relTime(ms) {
|
|
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
if (s < 60) return `${s}s ago`;
|
|
const m = Math.round(s / 60);
|
|
if (m < 60) return `${m}m ago`;
|
|
const h = Math.round(m / 60);
|
|
if (h < 48) return `${h}h ago`;
|
|
return `${Math.round(h / 24)}d ago`;
|
|
}
|
|
|
|
async function fetchControllers() {
|
|
try { controllersCache = await apiFetch('/api/controllers'); }
|
|
catch { controllersCache = []; }
|
|
return controllersCache;
|
|
}
|
|
|
|
function renderControllerDropdown() {
|
|
const sel = document.getElementById('controller-filter');
|
|
const prev = sel.value;
|
|
sel.innerHTML = '<option value="">All controllers</option>' +
|
|
controllersCache.map(c => `<option value="${c.id}">${escapeHtml(c.name)}${c.enabled ? '' : ' (disabled)'}</option>`).join('');
|
|
if (prev && controllersCache.some(c => c.id === prev)) sel.value = prev;
|
|
}
|
|
|
|
async function loadControllerList() {
|
|
await fetchControllers();
|
|
renderControllerDropdown();
|
|
updateHealth();
|
|
return controllersCache;
|
|
}
|
|
|
|
function updateHealth() {
|
|
const dot = document.getElementById('health-dot');
|
|
const text = document.getElementById('health-text');
|
|
if (!controllersCache.length) {
|
|
dot.className = 'dot idle'; text.textContent = 'no controllers'; return;
|
|
}
|
|
const enabled = controllersCache.filter(c => c.enabled);
|
|
const times = enabled.map(c => c.last_event_at).filter(Boolean)
|
|
.map(t => new Date(t).getTime()).filter(n => !isNaN(n));
|
|
if (!times.length) {
|
|
dot.className = 'dot idle';
|
|
text.textContent = enabled.some(c => !c.has_webhook) ? 'webhook not registered' : 'no events yet';
|
|
return;
|
|
}
|
|
const newest = Math.max(...times);
|
|
const ageMin = (Date.now() - newest) / 60000;
|
|
if (ageMin < 120) { dot.className = 'dot on'; text.textContent = `last badge ${relTime(newest)}`; }
|
|
else { dot.className = 'dot stale'; text.textContent = `no events ${relTime(newest)}`; }
|
|
}
|
|
|
|
function isViewingToday() {
|
|
return (document.getElementById('date').value || isoToday()) === isoToday();
|
|
}
|
|
|
|
function updateLiveIndicator() {
|
|
const dot = document.getElementById('live-dot');
|
|
const text = document.getElementById('live-text');
|
|
const auto = document.getElementById('auto-refresh').checked;
|
|
if (!isViewingToday()) { dot.className = 'dot idle'; text.textContent = 'viewing past day'; return; }
|
|
if (!auto) { dot.className = 'dot idle'; text.textContent = lastLoadedAt ? `paused · updated ${relTime(lastLoadedAt)}` : 'paused'; return; }
|
|
dot.className = 'dot on';
|
|
text.textContent = lastLoadedAt ? `updated ${relTime(lastLoadedAt)}` : 'live';
|
|
}
|
|
|
|
async function loadData() {
|
|
if (loadInFlight) return;
|
|
loadInFlight = true;
|
|
try {
|
|
const date = document.getElementById('date').value || isoToday();
|
|
const cutoff = document.getElementById('cutoff').value || '09:00';
|
|
const controllerId = document.getElementById('controller-filter').value;
|
|
const showFiltered = document.getElementById('show-filtered').checked;
|
|
const params = new URLSearchParams({ date, cutoff });
|
|
if (controllerId) params.set('controller_id', controllerId);
|
|
if (showFiltered) params.set('include_filtered', '1');
|
|
|
|
let data;
|
|
try {
|
|
data = await apiFetch('/api/first-badge-status?' + params.toString());
|
|
} catch (e) {
|
|
showToast(`Could not load data: ${e.message}`, true);
|
|
return;
|
|
}
|
|
currentRows = data;
|
|
lastLoadedAt = Date.now();
|
|
renderTable();
|
|
updateLiveIndicator();
|
|
fetchControllers().then(updateHealth); // refresh ingestion health in background
|
|
} finally {
|
|
loadInFlight = false;
|
|
}
|
|
}
|
|
|
|
function sortRows(rows) {
|
|
const { key, dir } = sortState;
|
|
const mul = dir === 'asc' ? 1 : -1;
|
|
const val = r => {
|
|
if (key === 'name') return (r.name || '').toLowerCase();
|
|
if (key === 'status') return r.status === 'ON TIME' ? 0 : 1;
|
|
if (key === 'latest_ts') return r.latest_ts || r.first_ts || '';
|
|
return r.first_ts || '';
|
|
};
|
|
return rows.sort((a, b) => {
|
|
const va = val(a), vb = val(b);
|
|
if (va < vb) return -mul;
|
|
if (va > vb) return mul;
|
|
return (a.name || '').localeCompare(b.name || '');
|
|
});
|
|
}
|
|
|
|
function updateSortIndicators() {
|
|
document.querySelectorAll('th.sortable').forEach(th => {
|
|
th.classList.remove('sorted-asc', 'sorted-desc');
|
|
const arrow = th.querySelector('.sort-arrow');
|
|
if (th.dataset.sort === sortState.key) {
|
|
th.classList.add(sortState.dir === 'asc' ? 'sorted-asc' : 'sorted-desc');
|
|
if (arrow) arrow.textContent = sortState.dir === 'asc' ? '▲' : '▼';
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderTable() {
|
|
const tbody = document.getElementById('table-body');
|
|
|
|
// Summary counts reflect the full day, independent of search/status filters.
|
|
let onTime = 0, late = 0;
|
|
currentRows.forEach(r => r.status === 'ON TIME' ? onTime++ : late++);
|
|
document.getElementById('on-time-count').textContent = onTime;
|
|
document.getElementById('late-count').textContent = late;
|
|
document.getElementById('total-count').textContent = onTime + late;
|
|
|
|
// First-run onboarding: nothing configured yet.
|
|
if (!controllersCache.length) {
|
|
tbody.innerHTML = `<tr class="empty-table-row"><td colspan="8">
|
|
<div class="onboard-cta">
|
|
<h3>No controllers configured</h3>
|
|
<p>Add a UniFi Access controller to start receiving badge-in events.<br>
|
|
The dashboard registers its webhook automatically.</p>
|
|
<button class="controllers-btn" id="onboard-add-btn">⚙ Add a Controller</button>
|
|
</div></td></tr>`;
|
|
return;
|
|
}
|
|
|
|
if (!currentRows.length) {
|
|
tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No badge-in records for this day.</td></tr>';
|
|
updateSortIndicators();
|
|
return;
|
|
}
|
|
|
|
const term = (document.getElementById('name-search').value || '').trim().toLowerCase();
|
|
let rows = currentRows.slice();
|
|
if (statusFilter) rows = rows.filter(r => r.status === statusFilter);
|
|
if (term) rows = rows.filter(r => (r.name || '').toLowerCase().includes(term));
|
|
rows = sortRows(rows);
|
|
|
|
if (!rows.length) {
|
|
tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No rows match the current filters.</td></tr>';
|
|
updateSortIndicators();
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = '';
|
|
rows.forEach((row, i) => {
|
|
const tr = document.createElement('tr');
|
|
if (row.filtered) tr.classList.add('row-filtered');
|
|
|
|
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 || '—'];
|
|
sources.forEach(s => {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'source-chip';
|
|
chip.textContent = s;
|
|
sourceWrap.appendChild(chip);
|
|
});
|
|
sourceTd.appendChild(sourceWrap);
|
|
if (row.merged) {
|
|
const pill = document.createElement('span');
|
|
pill.className = 'merged-pill';
|
|
pill.textContent = 'MERGED';
|
|
sourceTd.appendChild(pill);
|
|
}
|
|
tr.appendChild(sourceTd);
|
|
|
|
const firstTd = document.createElement('td');
|
|
firstTd.dataset.label = 'First Badge In';
|
|
if (row.first_ts) {
|
|
firstTd.className = 'time-first clickable times-trigger';
|
|
firstTd.textContent = row.first_ts;
|
|
firstTd.title = 'View all badge-in times';
|
|
firstTd.dataset.personId = row.person_id || '';
|
|
firstTd.dataset.controllerId = row.controller_id || '';
|
|
firstTd.dataset.actorId = row.actor_id || '';
|
|
firstTd.dataset.name = row.name || '';
|
|
} else {
|
|
firstTd.className = 'time-first';
|
|
firstTd.textContent = '—';
|
|
}
|
|
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';
|
|
} else {
|
|
latestTd.className = 'time-latest';
|
|
latestTd.textContent = row.latest_ts;
|
|
}
|
|
tr.appendChild(latestTd);
|
|
|
|
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');
|
|
statusChip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
|
|
statusTd.appendChild(statusChip);
|
|
tr.appendChild(statusTd);
|
|
|
|
const actionTd = document.createElement('td');
|
|
actionTd.className = 'align-center';
|
|
actionTd.dataset.label = 'Actions';
|
|
const actionGroup = document.createElement('div');
|
|
actionGroup.className = 'row-action-group';
|
|
|
|
const timesBtn = document.createElement('button');
|
|
timesBtn.className = 'row-action-btn times-btn';
|
|
timesBtn.textContent = 'Times';
|
|
timesBtn.dataset.personId = row.person_id || '';
|
|
timesBtn.dataset.controllerId = row.controller_id || '';
|
|
timesBtn.dataset.actorId = row.actor_id || '';
|
|
timesBtn.dataset.name = row.name || '';
|
|
actionGroup.appendChild(timesBtn);
|
|
|
|
const hideBtn = document.createElement('button');
|
|
hideBtn.className = 'row-action-btn hide-btn' + (row.filtered ? ' unhide' : '');
|
|
hideBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
|
|
hideBtn.dataset.personId = row.person_id || '';
|
|
hideBtn.dataset.controllerId = row.controller_id || '';
|
|
hideBtn.dataset.actorId = row.actor_id || '';
|
|
hideBtn.dataset.filtered = row.filtered ? '1' : '0';
|
|
hideBtn.dataset.name = row.name || '';
|
|
actionGroup.appendChild(hideBtn);
|
|
|
|
const mergeBtn = document.createElement('button');
|
|
mergeBtn.className = 'row-action-btn merge merge-btn';
|
|
mergeBtn.textContent = row.merged ? 'Manage' : 'Merge';
|
|
mergeBtn.dataset.personId = row.person_id || '';
|
|
mergeBtn.dataset.controllerId = row.controller_id || '';
|
|
mergeBtn.dataset.actorId = row.actor_id || '';
|
|
mergeBtn.dataset.name = row.name || '';
|
|
actionGroup.appendChild(mergeBtn);
|
|
|
|
actionTd.appendChild(actionGroup);
|
|
tr.appendChild(actionTd);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
updateSortIndicators();
|
|
}
|
|
|
|
// ----- Badge-in times modal -----
|
|
async function openTimesModal(subject, dateStr) {
|
|
const list = document.getElementById('times-list');
|
|
document.getElementById('times-subhead').innerHTML =
|
|
`All badge-ins for <strong>${escapeHtml(subject.name)}</strong> on <strong>${escapeHtml(dateStr)}</strong>.`;
|
|
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
|
openModal('times-modal', '#times-close');
|
|
|
|
const p = new URLSearchParams({ date: dateStr });
|
|
if (subject.personId) {
|
|
p.set('person_id', subject.personId);
|
|
} else if (subject.controllerId && subject.actorId) {
|
|
p.set('controller_id', subject.controllerId);
|
|
p.set('actor_id', subject.actorId);
|
|
} else {
|
|
list.innerHTML = '<div class="empty-state">Missing identity for this row.</div>';
|
|
return;
|
|
}
|
|
|
|
let events;
|
|
try {
|
|
events = await apiFetch('/api/badge-events?' + p.toString());
|
|
} catch (e) {
|
|
list.innerHTML = `<div class="empty-state">Failed to load times: ${escapeHtml(e.message)}</div>`;
|
|
return;
|
|
}
|
|
if (!events.length) {
|
|
list.innerHTML = '<div class="empty-state">No badge-in events recorded for this day.</div>';
|
|
return;
|
|
}
|
|
const last = events.length - 1;
|
|
list.innerHTML = events.map((e, i) => {
|
|
const tags =
|
|
(i === 0 ? '<span class="t-tag">first</span>' : '') +
|
|
(i === last && events.length > 1 ? '<span class="t-tag">latest</span>' : '');
|
|
return `<div class="time-entry${i === 0 ? ' is-first' : ''}">
|
|
<span class="t-idx">${i + 1}</span>
|
|
<span class="t-time">${escapeHtml(e.ts)}${tags}</span>
|
|
<span class="source-chip">${escapeHtml(e.source)}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
document.getElementById('times-close').addEventListener('click', () => closeModal('times-modal'));
|
|
document.getElementById('times-modal').addEventListener('click', e => {
|
|
if (e.target.id === 'times-modal') closeModal('times-modal');
|
|
});
|
|
|
|
async function setActorFiltered(controllerId, actorId, filtered) {
|
|
if (!controllerId || !actorId) {
|
|
showToast('Missing controller or actor id', true);
|
|
return false;
|
|
}
|
|
try {
|
|
await apiFetch(`/api/users/${controllerId}/${actorId}`, {
|
|
method: 'PATCH',
|
|
json: { filtered },
|
|
});
|
|
} catch (e) {
|
|
showToast(`Failed: ${e.message}`, true);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function setPersonFiltered(personId, filtered) {
|
|
try {
|
|
await apiFetch(`/api/persons/${personId}`, {
|
|
method: 'PATCH',
|
|
json: { filtered },
|
|
});
|
|
} catch (e) {
|
|
showToast(`Failed: ${e.message}`, true);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
document.getElementById('table-body').addEventListener('click', async e => {
|
|
const onboardBtn = e.target.closest('#onboard-add-btn');
|
|
if (onboardBtn) {
|
|
document.getElementById('open-controllers-btn').click();
|
|
return;
|
|
}
|
|
|
|
const timesEl = e.target.closest('.times-trigger, .times-btn');
|
|
if (timesEl) {
|
|
await openTimesModal(
|
|
{
|
|
personId: timesEl.dataset.personId,
|
|
controllerId: timesEl.dataset.controllerId,
|
|
actorId: timesEl.dataset.actorId,
|
|
name: timesEl.dataset.name,
|
|
},
|
|
document.getElementById('date').value || isoToday(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const hideBtn = e.target.closest('.hide-btn');
|
|
const mergeBtn = e.target.closest('.merge-btn');
|
|
|
|
if (hideBtn) {
|
|
const wasFiltered = hideBtn.dataset.filtered === '1';
|
|
hideBtn.disabled = true;
|
|
let ok;
|
|
if (hideBtn.dataset.personId) {
|
|
ok = await setPersonFiltered(hideBtn.dataset.personId, !wasFiltered);
|
|
} else {
|
|
ok = await setActorFiltered(hideBtn.dataset.controllerId, hideBtn.dataset.actorId, !wasFiltered);
|
|
}
|
|
hideBtn.disabled = false;
|
|
if (!ok) return;
|
|
showToast(wasFiltered ? `Unhid ${hideBtn.dataset.name}` : `Hid ${hideBtn.dataset.name}`);
|
|
await loadData();
|
|
return;
|
|
}
|
|
|
|
if (mergeBtn) {
|
|
if (mergeBtn.dataset.personId) {
|
|
// Already merged — open People modal scrolled to this person
|
|
openModal('people-modal', '#people-close');
|
|
await renderSuggestions();
|
|
await renderPeople(mergeBtn.dataset.personId);
|
|
} else {
|
|
await openMergeModal({
|
|
controllerId: mergeBtn.dataset.controllerId,
|
|
actorId: mergeBtn.dataset.actorId,
|
|
name: mergeBtn.dataset.name,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
async function syncUsers() {
|
|
const btn = document.getElementById('sync-btn');
|
|
const orig = btn.innerHTML;
|
|
btn.textContent = 'Syncing…'; btn.disabled = true;
|
|
try {
|
|
await apiFetch('/api/sync-users');
|
|
showToast('User list synced from all controllers');
|
|
await loadData();
|
|
} catch (e) {
|
|
showToast(`Sync failed: ${e.message}`, true);
|
|
} finally {
|
|
btn.innerHTML = orig; btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -------- Reset day modal --------
|
|
document.getElementById('reset-btn').addEventListener('click', () => {
|
|
const date = document.getElementById('date').value || isoToday();
|
|
document.getElementById('modal-date-label').textContent = date;
|
|
openModal('reset-modal', '#modal-cancel');
|
|
});
|
|
document.getElementById('modal-cancel').addEventListener('click', () => {
|
|
closeModal('reset-modal');
|
|
});
|
|
document.getElementById('modal-confirm').addEventListener('click', async () => {
|
|
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 json = await apiFetch('/api/reset-day?' + params.toString(), { method: 'DELETE' });
|
|
showToast(`Reset complete — ${json.deleted} record(s) deleted for ${date}`);
|
|
await loadData();
|
|
} catch (e) {
|
|
showToast(`Reset failed: ${e.message}`, true);
|
|
}
|
|
});
|
|
document.getElementById('reset-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('reset-modal'))
|
|
closeModal('reset-modal');
|
|
});
|
|
|
|
// -------- Controllers modal --------
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return 'never';
|
|
const d = new Date(iso);
|
|
return isNaN(d) ? iso : d.toLocaleString();
|
|
}
|
|
|
|
async function renderControllers() {
|
|
const list = document.getElementById('ctrl-list');
|
|
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
|
|
|
let items = [];
|
|
try {
|
|
items = await apiFetch('/api/controllers');
|
|
} catch {
|
|
list.innerHTML = '<div class="empty-state">Failed to load controllers.</div>';
|
|
return;
|
|
}
|
|
|
|
if (!items.length) {
|
|
list.innerHTML = '<div class="empty-state">No controllers configured. Add one below to start receiving badge events.</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = '';
|
|
items.forEach(c => {
|
|
const row = document.createElement('div');
|
|
row.className = 'ctrl-row';
|
|
row.innerHTML = `
|
|
<div class="ctrl-meta">
|
|
<div class="ctrl-name">
|
|
${escapeHtml(c.name)}
|
|
${c.enabled ? '' : '<span class="disabled-tag">(disabled)</span>'}
|
|
</div>
|
|
<div class="ctrl-sub">${escapeHtml(c.host)}:${c.port} · last sync ${fmtDate(c.last_sync_at)} · last event ${c.last_event_at ? fmtDate(c.last_event_at) : '<span style="color:var(--warn)">never</span>'}${c.has_webhook ? '' : ' · <span style="color:var(--danger)">no webhook</span>'}</div>
|
|
</div>
|
|
<div class="ctrl-actions">
|
|
<button class="small-btn sync-btn" data-act="test" data-id="${c.id}">Test</button>
|
|
<button class="small-btn sync-btn" data-act="sync" data-id="${c.id}">Sync</button>
|
|
<button class="small-btn" data-act="toggle" data-id="${c.id}">${c.enabled ? 'Disable' : 'Enable'}</button>
|
|
<button class="small-btn reset-btn" data-act="delete" data-id="${c.id}" data-name="${escapeHtml(c.name)}">Remove</button>
|
|
</div>
|
|
`;
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
document.getElementById('open-controllers-btn').addEventListener('click', async () => {
|
|
document.getElementById('base-url-hint').textContent = window.location.origin;
|
|
document.getElementById('add-error').textContent = '';
|
|
openModal('controllers-modal', '#controllers-close');
|
|
await renderControllers();
|
|
});
|
|
|
|
document.getElementById('controllers-close').addEventListener('click', () => {
|
|
closeModal('controllers-modal');
|
|
});
|
|
|
|
document.getElementById('controllers-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('controllers-modal'))
|
|
closeModal('controllers-modal');
|
|
});
|
|
|
|
document.getElementById('ctrl-list').addEventListener('click', async e => {
|
|
const btn = e.target.closest('button[data-act]');
|
|
if (!btn) return;
|
|
const id = btn.dataset.id;
|
|
const act = btn.dataset.act;
|
|
btn.disabled = true;
|
|
try {
|
|
if (act === 'test') {
|
|
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 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 apiFetch(`/api/controllers/${id}`, {
|
|
method: 'PATCH',
|
|
json: { enabled: isEnabling },
|
|
});
|
|
await renderControllers();
|
|
await loadControllerList();
|
|
} else if (act === 'delete') {
|
|
const name = btn.dataset.name;
|
|
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;
|
|
await apiFetch(`/api/controllers/${id}`, { method: 'DELETE' });
|
|
showToast(`Removed ${name}`);
|
|
await renderControllers();
|
|
await loadControllerList();
|
|
await loadData();
|
|
}
|
|
} catch (err) {
|
|
showToast(`Action failed: ${err.message || err}`, true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.getElementById('add-controller-btn').addEventListener('click', async () => {
|
|
const btn = document.getElementById('add-controller-btn');
|
|
const err = document.getElementById('add-error');
|
|
err.textContent = '';
|
|
|
|
const body = {
|
|
name: document.getElementById('add-name').value.trim(),
|
|
host: document.getElementById('add-host').value.trim(),
|
|
port: parseInt(document.getElementById('add-port').value, 10) || 12445,
|
|
api_token: document.getElementById('add-token').value.trim(),
|
|
};
|
|
if (!body.name || !body.host || !body.api_token) {
|
|
err.textContent = 'Name, host, and API token are required.';
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Adding…';
|
|
try {
|
|
const j = await apiFetch('/api/controllers', {
|
|
method: 'POST',
|
|
json: body,
|
|
});
|
|
document.getElementById('add-name').value = '';
|
|
document.getElementById('add-host').value = '';
|
|
document.getElementById('add-token').value = '';
|
|
showToast(`Added ${j.name}`);
|
|
await renderControllers();
|
|
await loadControllerList();
|
|
await loadData();
|
|
} catch (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';
|
|
}
|
|
});
|
|
|
|
// -------- Filtered tenants modal --------
|
|
async function renderFiltered() {
|
|
const list = document.getElementById('filtered-list');
|
|
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
|
let items = [];
|
|
try {
|
|
items = await apiFetch('/api/users?filtered=1');
|
|
} catch {
|
|
list.innerHTML = '<div class="empty-state">Failed to load filtered tenants.</div>';
|
|
return;
|
|
}
|
|
if (!items.length) {
|
|
list.innerHTML = '<div class="empty-state">No filtered tenants. Use the <span>Hide</span> button on any row to filter someone out.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = '';
|
|
items.forEach(u => {
|
|
const row = document.createElement('div');
|
|
row.className = 'ctrl-row';
|
|
row.innerHTML = `
|
|
<div class="ctrl-meta">
|
|
<div class="ctrl-name">${escapeHtml(u.full_name)}</div>
|
|
<div class="ctrl-sub">${escapeHtml(u.controller_name)} · ${escapeHtml((u.actor_id || '').slice(0,8))}…</div>
|
|
</div>
|
|
<div class="ctrl-actions">
|
|
<button class="small-btn sync-btn" data-act="unhide"
|
|
data-controller-id="${escapeHtml(u.controller_id)}"
|
|
data-actor-id="${escapeHtml(u.actor_id)}"
|
|
data-name="${escapeHtml(u.full_name)}">Unhide</button>
|
|
</div>
|
|
`;
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
document.getElementById('open-filtered-btn').addEventListener('click', async () => {
|
|
openModal('filtered-modal', '#filtered-close');
|
|
await renderFiltered();
|
|
});
|
|
document.getElementById('filtered-close').addEventListener('click', () => {
|
|
closeModal('filtered-modal');
|
|
});
|
|
document.getElementById('filtered-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('filtered-modal'))
|
|
closeModal('filtered-modal');
|
|
});
|
|
document.getElementById('filtered-list').addEventListener('click', async e => {
|
|
const btn = e.target.closest('button[data-act="unhide"]');
|
|
if (!btn) return;
|
|
btn.disabled = true;
|
|
const ok = await setActorFiltered(btn.dataset.controllerId, btn.dataset.actorId, false);
|
|
btn.disabled = false;
|
|
if (!ok) return;
|
|
showToast(`Unhid ${btn.dataset.name}`);
|
|
await renderFiltered();
|
|
await loadData();
|
|
});
|
|
|
|
// -------- Merge picker modal --------
|
|
let mergeSource = null; // {controllerId, actorId, name}
|
|
let mergeCandidates = []; // cached user list
|
|
let mergeSelected = null; // {controllerId, actorId, name}
|
|
|
|
function fuzzyScore(needle, hay) {
|
|
needle = (needle || '').toLowerCase().trim();
|
|
hay = (hay || '').toLowerCase();
|
|
if (!needle) return 1;
|
|
if (hay.includes(needle)) return 2;
|
|
// simple subsequence check
|
|
let i = 0;
|
|
for (const ch of hay) { if (ch === needle[i]) i++; if (i === needle.length) return 1; }
|
|
return 0;
|
|
}
|
|
|
|
function renderMergeCandidates() {
|
|
const search = document.getElementById('merge-search').value;
|
|
const list = document.getElementById('merge-candidates');
|
|
const items = mergeCandidates
|
|
.filter(u => !(u.controller_id === mergeSource.controllerId && u.actor_id === mergeSource.actorId))
|
|
.map(u => ({ u, score: search ? fuzzyScore(search, u.full_name) : fuzzyScore(mergeSource.name, u.full_name) }))
|
|
.filter(x => x.score > 0)
|
|
.sort((a, b) => b.score - a.score || a.u.full_name.localeCompare(b.u.full_name));
|
|
|
|
if (!items.length) {
|
|
list.innerHTML = '<div class="empty-state">No matching candidates. Try clearing the search.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = '';
|
|
items.forEach(({ u }) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'picker-row';
|
|
const selected = mergeSelected
|
|
&& mergeSelected.controllerId === u.controller_id
|
|
&& mergeSelected.actorId === u.actor_id;
|
|
row.innerHTML = `
|
|
<div class="ctrl-meta">
|
|
<div class="ctrl-name">${escapeHtml(u.full_name)}</div>
|
|
<div class="ctrl-sub">${escapeHtml(u.controller_name)} · ${escapeHtml((u.actor_id || '').slice(0,8))}…</div>
|
|
</div>
|
|
<button class="small-btn ${selected ? 'sync-btn' : ''}"
|
|
data-controller-id="${escapeHtml(u.controller_id)}"
|
|
data-actor-id="${escapeHtml(u.actor_id)}"
|
|
data-name="${escapeHtml(u.full_name)}">${selected ? 'Selected' : 'Select'}</button>
|
|
`;
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function openMergeModal(source) {
|
|
mergeSource = source;
|
|
mergeSelected = null;
|
|
document.getElementById('merge-source-name').textContent = source.name;
|
|
document.getElementById('merge-display-name').value = source.name;
|
|
document.getElementById('merge-search').value = '';
|
|
document.getElementById('merge-error').textContent = '';
|
|
openModal('merge-modal', '#merge-search');
|
|
|
|
try {
|
|
mergeCandidates = await apiFetch('/api/users');
|
|
} catch {
|
|
mergeCandidates = [];
|
|
}
|
|
// Only show actors not already in a person, by querying the persons API
|
|
let merged = [];
|
|
try {
|
|
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);
|
|
mergeCandidates = mergeCandidates.filter(u => !mergedSet.has(`${u.controller_id}|${u.actor_id}`));
|
|
renderMergeCandidates();
|
|
}
|
|
|
|
document.getElementById('merge-search').addEventListener('input', renderMergeCandidates);
|
|
document.getElementById('merge-candidates').addEventListener('click', e => {
|
|
const btn = e.target.closest('button[data-controller-id]');
|
|
if (!btn) return;
|
|
mergeSelected = {
|
|
controllerId: btn.dataset.controllerId,
|
|
actorId: btn.dataset.actorId,
|
|
name: btn.dataset.name,
|
|
};
|
|
renderMergeCandidates();
|
|
});
|
|
document.getElementById('merge-cancel').addEventListener('click', () => {
|
|
closeModal('merge-modal');
|
|
});
|
|
document.getElementById('merge-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('merge-modal'))
|
|
closeModal('merge-modal');
|
|
});
|
|
document.getElementById('merge-confirm').addEventListener('click', async () => {
|
|
const err = document.getElementById('merge-error');
|
|
err.textContent = '';
|
|
if (!mergeSelected) {
|
|
err.textContent = 'Pick a candidate to merge with.';
|
|
return;
|
|
}
|
|
const displayName = document.getElementById('merge-display-name').value.trim();
|
|
if (!displayName) {
|
|
err.textContent = 'Display name is required.';
|
|
return;
|
|
}
|
|
const btn = document.getElementById('merge-confirm');
|
|
btn.disabled = true; btn.textContent = 'Merging…';
|
|
try {
|
|
await apiFetch('/api/persons', {
|
|
method: 'POST',
|
|
json: {
|
|
display_name: displayName,
|
|
members: [
|
|
{ controller_id: mergeSource.controllerId, actor_id: mergeSource.actorId },
|
|
{ controller_id: mergeSelected.controllerId, actor_id: mergeSelected.actorId },
|
|
],
|
|
},
|
|
});
|
|
closeModal('merge-modal');
|
|
showToast(`Merged into "${displayName}"`);
|
|
await loadData();
|
|
} catch (e) {
|
|
err.textContent = e.message || 'Merge failed';
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = 'Merge';
|
|
}
|
|
});
|
|
|
|
// -------- People modal --------
|
|
async function renderSuggestions() {
|
|
const list = document.getElementById('suggestions-list');
|
|
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
|
let items = [];
|
|
try {
|
|
items = await apiFetch('/api/persons/suggestions');
|
|
} catch {
|
|
list.innerHTML = '<div class="empty-state">Failed to load suggestions.</div>';
|
|
return;
|
|
}
|
|
if (!items.length) {
|
|
list.innerHTML = '<div class="empty-state">No suggestions. Names already match a person or are unique per controller.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = '';
|
|
items.forEach(s => {
|
|
const row = document.createElement('div');
|
|
row.className = 'suggestion-row';
|
|
const controllerSummary = s.members.map(m => escapeHtml(m.controller_name)).join(', ');
|
|
row.innerHTML = `
|
|
<div class="ctrl-meta">
|
|
<div class="ctrl-name">${escapeHtml(s.display_name)}</div>
|
|
<div class="ctrl-sub">${controllerSummary} · ${s.members.length} identities</div>
|
|
</div>
|
|
<button class="small-btn sync-btn" data-act="merge"
|
|
data-payload='${escapeHtml(JSON.stringify(s))}'>Merge</button>
|
|
`;
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function renderPeople(scrollToId) {
|
|
const list = document.getElementById('people-list');
|
|
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
|
let items = [];
|
|
try {
|
|
items = await apiFetch('/api/persons');
|
|
} catch {
|
|
list.innerHTML = '<div class="empty-state">Failed to load people.</div>';
|
|
return;
|
|
}
|
|
if (!items.length) {
|
|
list.innerHTML = '<div class="empty-state">No merged people yet. Use the <span>Merge</span> button on a row.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = '';
|
|
items.forEach(p => {
|
|
const row = document.createElement('div');
|
|
row.className = 'ctrl-row';
|
|
row.dataset.personId = p.id;
|
|
const memberLines = p.members.map(m =>
|
|
`<div class="ctrl-sub">${escapeHtml(m.controller_name)} · ${escapeHtml(m.full_name)}
|
|
<button class="row-action-btn" data-act="unmerge-member"
|
|
data-person-id="${escapeHtml(p.id)}"
|
|
data-controller-id="${escapeHtml(m.controller_id)}"
|
|
data-actor-id="${escapeHtml(m.actor_id)}"
|
|
data-name="${escapeHtml(m.full_name)}">Split off</button>
|
|
</div>`
|
|
).join('');
|
|
row.innerHTML = `
|
|
<div class="ctrl-meta">
|
|
<div class="ctrl-name">
|
|
${escapeHtml(p.display_name)}
|
|
${p.filtered ? '<span class="disabled-tag">(filtered)</span>' : ''}
|
|
</div>
|
|
${memberLines}
|
|
</div>
|
|
<div class="ctrl-actions">
|
|
<button class="small-btn" data-act="rename" data-person-id="${escapeHtml(p.id)}"
|
|
data-name="${escapeHtml(p.display_name)}">Rename</button>
|
|
<button class="small-btn reset-btn" data-act="dissolve" data-person-id="${escapeHtml(p.id)}"
|
|
data-name="${escapeHtml(p.display_name)}">Dissolve</button>
|
|
</div>
|
|
`;
|
|
list.appendChild(row);
|
|
});
|
|
if (scrollToId) {
|
|
const target = list.querySelector(`.ctrl-row[data-person-id="${scrollToId}"]`);
|
|
if (target) target.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
document.getElementById('open-people-btn').addEventListener('click', async () => {
|
|
openModal('people-modal', '#people-close');
|
|
await renderSuggestions();
|
|
await renderPeople();
|
|
});
|
|
document.getElementById('people-close').addEventListener('click', () => {
|
|
closeModal('people-modal');
|
|
});
|
|
document.getElementById('people-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('people-modal'))
|
|
closeModal('people-modal');
|
|
});
|
|
|
|
document.getElementById('suggestions-list').addEventListener('click', async e => {
|
|
const btn = e.target.closest('button[data-act="merge"]');
|
|
if (!btn) return;
|
|
btn.disabled = true;
|
|
let payload;
|
|
try { payload = JSON.parse(btn.dataset.payload); }
|
|
catch { btn.disabled = false; return; }
|
|
try {
|
|
await apiFetch('/api/persons', {
|
|
method: 'POST',
|
|
json: {
|
|
display_name: payload.display_name,
|
|
members: payload.members.map(m => ({
|
|
controller_id: m.controller_id, actor_id: m.actor_id,
|
|
})),
|
|
},
|
|
});
|
|
showToast(`Merged "${payload.display_name}"`);
|
|
await renderSuggestions();
|
|
await renderPeople();
|
|
await loadData();
|
|
} catch (e) {
|
|
showToast(`Merge failed: ${e.message}`, true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.getElementById('people-list').addEventListener('click', async e => {
|
|
const btn = e.target.closest('button[data-act]');
|
|
if (!btn) return;
|
|
const act = btn.dataset.act;
|
|
btn.disabled = true;
|
|
try {
|
|
if (act === 'rename') {
|
|
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() },
|
|
});
|
|
showToast(`Renamed to "${name.trim()}"`);
|
|
await renderPeople();
|
|
await loadData();
|
|
} else if (act === 'dissolve') {
|
|
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;
|
|
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') {
|
|
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}`;
|
|
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;
|
|
}
|
|
});
|
|
|
|
// -------- Report --------
|
|
let reportSubjects = [];
|
|
const reportSelected = new Set();
|
|
|
|
function reportParams(extra = {}) {
|
|
const p = new URLSearchParams({
|
|
start: document.getElementById('report-start').value || isoToday(),
|
|
end: document.getElementById('report-end').value || isoToday(),
|
|
cutoff: document.getElementById('report-cutoff').value || '09:00',
|
|
});
|
|
const cid = document.getElementById('controller-filter').value;
|
|
if (cid) p.set('controller_id', cid);
|
|
if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1');
|
|
if (reportSelected.size) p.set('subjects', [...reportSelected].join(','));
|
|
Object.entries(extra).forEach(([k, v]) => p.set(k, v));
|
|
return p;
|
|
}
|
|
|
|
function updateReportSelCount() {
|
|
document.getElementById('report-sel-count').textContent =
|
|
`${reportSelected.size} selected`;
|
|
}
|
|
|
|
function renderReportSubjects() {
|
|
const term = document.getElementById('report-search').value.trim().toLowerCase();
|
|
const box = document.getElementById('report-subjects');
|
|
const list = reportSubjects.filter(s => s.name.toLowerCase().includes(term));
|
|
if (!list.length) {
|
|
box.innerHTML = '<div class="empty-state">No matching users.</div>';
|
|
return;
|
|
}
|
|
box.innerHTML = list.map(s => `
|
|
<label class="subject-row">
|
|
<input type="checkbox" data-key="${escapeHtml(s.key)}" ${reportSelected.has(s.key) ? 'checked' : ''}>
|
|
<span>${escapeHtml(s.name)}${s.merged ? ' <span class="merged-pill">MERGED</span>' : ''}</span>
|
|
<span class="s-sub">${escapeHtml(s.controller_name || '')}</span>
|
|
</label>
|
|
`).join('');
|
|
updateReportSelCount();
|
|
}
|
|
|
|
async function loadReportSubjects() {
|
|
const box = document.getElementById('report-subjects');
|
|
box.innerHTML = '<div class="empty-state">Loading…</div>';
|
|
const p = new URLSearchParams();
|
|
const cid = document.getElementById('controller-filter').value;
|
|
if (cid) p.set('controller_id', cid);
|
|
if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1');
|
|
try {
|
|
reportSubjects = await apiFetch('/api/report/subjects?' + p.toString());
|
|
} catch (e) {
|
|
reportSubjects = [];
|
|
showToast(`Could not load users: ${e.message}`, true);
|
|
}
|
|
renderReportSubjects();
|
|
}
|
|
|
|
async function runReport() {
|
|
const errEl = document.getElementById('report-error');
|
|
errEl.textContent = '';
|
|
try {
|
|
const data = await apiFetch('/api/report?' + reportParams().toString());
|
|
renderPivot(data);
|
|
} catch (e) {
|
|
errEl.textContent = e.message || 'Report failed';
|
|
return;
|
|
}
|
|
}
|
|
|
|
function renderPivot(data) {
|
|
const summary = document.getElementById('report-summary');
|
|
const result = document.getElementById('report-result');
|
|
if (!data.subjects.length) {
|
|
summary.textContent = '';
|
|
result.innerHTML = '<div class="empty-state">No users selected (or none match).</div>';
|
|
return;
|
|
}
|
|
|
|
// index rows by subject_key + date
|
|
const byKey = {};
|
|
data.rows.forEach(r => {
|
|
(byKey[r.subject_key] = byKey[r.subject_key] || {})[r.date] = r;
|
|
});
|
|
|
|
let late = 0, absent = 0, present = 0;
|
|
data.rows.forEach(r => {
|
|
if (r.status === 'LATE') late++;
|
|
else if (r.status === 'ABSENT') absent++;
|
|
else present++;
|
|
});
|
|
summary.textContent =
|
|
`${data.subjects.length} user(s) · ${data.dates.length} day(s) · ` +
|
|
`${present} on time · ${late} late · ${absent} absent`;
|
|
|
|
const isWeekend = d => { const g = new Date(d + 'T00:00:00').getDay(); return g === 0 || g === 6; };
|
|
const head = '<th class="name-col">Name</th>' + data.dates.map(d => {
|
|
const label = d.slice(5); // MM-DD
|
|
return `<th class="${isWeekend(d) ? 'weekend' : ''}">${label}</th>`;
|
|
}).join('') +
|
|
'<th class="total-col cell-on">On time</th>' +
|
|
'<th class="total-col cell-late">Late</th>' +
|
|
'<th class="total-col cell-absent">Absent</th>';
|
|
|
|
// Column totals accumulate as we walk each subject row.
|
|
const colTot = { on: 0, late: 0, absent: 0 };
|
|
|
|
const body = data.subjects.map(s => {
|
|
let sOn = 0, sLate = 0, sAbsent = 0;
|
|
const cells = data.dates.map(d => {
|
|
const r = byKey[s.key] && byKey[s.key][d];
|
|
const wknd = isWeekend(d) ? ' weekend' : '';
|
|
if (!r || r.status === 'ABSENT') {
|
|
sAbsent++;
|
|
return `<td class="cell-absent${wknd}">—</td>`;
|
|
}
|
|
if (r.status === 'LATE') sLate++; else sOn++;
|
|
const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on';
|
|
return `<td class="${cls}${wknd}">${escapeHtml(r.first_ts)}</td>`;
|
|
}).join('');
|
|
colTot.on += sOn; colTot.late += sLate; colTot.absent += sAbsent;
|
|
return `<tr><td class="name-col">${escapeHtml(s.name)}</td>${cells}` +
|
|
`<td class="total-col">${sOn}</td>` +
|
|
`<td class="total-col tot-late">${sLate}</td>` +
|
|
`<td class="total-col tot-absent">${sAbsent}</td></tr>`;
|
|
}).join('');
|
|
|
|
const totalsRow =
|
|
`<tr class="totals-row"><td class="name-col">All (${data.subjects.length})</td>` +
|
|
data.dates.map(() => '<td></td>').join('') +
|
|
`<td class="total-col">${colTot.on}</td>` +
|
|
`<td class="total-col tot-late">${colTot.late}</td>` +
|
|
`<td class="total-col tot-absent">${colTot.absent}</td></tr>`;
|
|
|
|
result.innerHTML =
|
|
`<table class="pivot"><thead><tr>${head}</tr></thead><tbody>${body}${totalsRow}</tbody></table>`;
|
|
}
|
|
|
|
function openReportModal() {
|
|
const today = isoToday();
|
|
const start = new Date(); start.setDate(start.getDate() - 6);
|
|
document.getElementById('report-start').value = isoToday(start);
|
|
document.getElementById('report-end').value = today;
|
|
document.getElementById('report-cutoff').value =
|
|
document.getElementById('cutoff').value || '09:00';
|
|
document.getElementById('report-search').value = '';
|
|
document.getElementById('report-error').textContent = '';
|
|
document.getElementById('report-summary').textContent = '';
|
|
document.getElementById('report-result').innerHTML = '';
|
|
reportSelected.clear();
|
|
updateReportSelCount();
|
|
openModal('report-modal', '#report-search');
|
|
loadReportSubjects();
|
|
}
|
|
|
|
document.getElementById('open-report-btn').addEventListener('click', openReportModal);
|
|
document.getElementById('report-close').addEventListener('click', () => {
|
|
closeModal('report-modal');
|
|
});
|
|
document.getElementById('report-modal').addEventListener('click', e => {
|
|
if (e.target.id === 'report-modal') closeModal('report-modal');
|
|
});
|
|
document.getElementById('report-search').addEventListener('input', renderReportSubjects);
|
|
document.getElementById('report-subjects').addEventListener('change', e => {
|
|
const cb = e.target.closest('input[type=checkbox]');
|
|
if (!cb) return;
|
|
if (cb.checked) reportSelected.add(cb.dataset.key);
|
|
else reportSelected.delete(cb.dataset.key);
|
|
updateReportSelCount();
|
|
});
|
|
document.getElementById('report-select-all').addEventListener('click', () => {
|
|
const term = document.getElementById('report-search').value.trim().toLowerCase();
|
|
reportSubjects.filter(s => s.name.toLowerCase().includes(term))
|
|
.forEach(s => reportSelected.add(s.key));
|
|
renderReportSubjects();
|
|
});
|
|
document.getElementById('report-select-none').addEventListener('click', () => {
|
|
reportSelected.clear();
|
|
renderReportSubjects();
|
|
});
|
|
document.getElementById('report-run-btn').addEventListener('click', runReport);
|
|
document.getElementById('report-csv-btn').addEventListener('click', async () => {
|
|
const btn = document.getElementById('report-csv-btn');
|
|
const errEl = document.getElementById('report-error');
|
|
errEl.textContent = '';
|
|
const orig = btn.innerHTML;
|
|
btn.disabled = true; btn.textContent = 'Exporting…';
|
|
try {
|
|
const res = await fetch('/api/report?' + reportParams({ format: 'csv' }).toString());
|
|
if (!res.ok) {
|
|
let detail = `Export failed (${res.status})`;
|
|
try { const j = await res.json(); detail = j.error || detail; } catch {}
|
|
throw new Error(detail);
|
|
}
|
|
const blob = await res.blob();
|
|
const cd = res.headers.get('content-disposition') || '';
|
|
const m = cd.match(/filename="([^"]+)"/);
|
|
const filename = m ? m[1] : 'badge-report.csv';
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename;
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
URL.revokeObjectURL(url);
|
|
showToast('CSV downloaded');
|
|
} catch (e) {
|
|
errEl.textContent = e.message || 'Export failed';
|
|
} finally {
|
|
btn.disabled = false; btn.innerHTML = orig;
|
|
}
|
|
});
|
|
|
|
// -------- View state persistence (localStorage + shareable URL) --------
|
|
function saveState() {
|
|
const s = {
|
|
cutoff: document.getElementById('cutoff').value,
|
|
controller: document.getElementById('controller-filter').value,
|
|
showFiltered: document.getElementById('show-filtered').checked,
|
|
autoRefresh: document.getElementById('auto-refresh').checked,
|
|
};
|
|
try { localStorage.setItem('uad_view', JSON.stringify(s)); } catch {}
|
|
|
|
const p = new URLSearchParams();
|
|
const date = document.getElementById('date').value;
|
|
if (date && date !== isoToday()) p.set('date', date);
|
|
if (s.cutoff && s.cutoff !== '09:00') p.set('cutoff', s.cutoff);
|
|
if (s.controller) p.set('controller_id', s.controller);
|
|
if (s.showFiltered) p.set('include_filtered', '1');
|
|
const qs = p.toString();
|
|
history.replaceState(null, '', qs ? '?' + qs : location.pathname);
|
|
}
|
|
|
|
function restoreState() {
|
|
let saved = {};
|
|
try { saved = JSON.parse(localStorage.getItem('uad_view') || '{}'); } catch {}
|
|
const url = new URLSearchParams(location.search);
|
|
|
|
document.getElementById('date').value = url.get('date') || isoToday();
|
|
document.getElementById('cutoff').value = url.get('cutoff') || saved.cutoff || '09:00';
|
|
document.getElementById('show-filtered').checked = url.get('include_filtered') === '1' || !!saved.showFiltered;
|
|
document.getElementById('auto-refresh').checked = saved.autoRefresh !== false; // default on
|
|
pendingController = url.get('controller_id') || saved.controller || '';
|
|
}
|
|
|
|
// -------- Wire up --------
|
|
document.getElementById('refresh-btn').addEventListener('click', loadData);
|
|
document.getElementById('sync-btn').addEventListener('click', syncUsers);
|
|
document.getElementById('controller-filter').addEventListener('change', () => { saveState(); loadData(); });
|
|
document.getElementById('show-filtered').addEventListener('change', () => { saveState(); loadData(); });
|
|
document.getElementById('date').addEventListener('change', () => { saveState(); loadData(); });
|
|
document.getElementById('cutoff').addEventListener('change', () => { saveState(); loadData(); });
|
|
document.getElementById('auto-refresh').addEventListener('change', () => { saveState(); updateLiveIndicator(); });
|
|
document.getElementById('name-search').addEventListener('input', renderTable);
|
|
document.getElementById('today-btn').addEventListener('click', () => {
|
|
document.getElementById('date').value = isoToday();
|
|
saveState();
|
|
loadData();
|
|
});
|
|
|
|
// Sortable column headers.
|
|
document.querySelectorAll('th.sortable').forEach(th => {
|
|
th.addEventListener('click', () => {
|
|
const key = th.dataset.sort;
|
|
if (sortState.key === key) sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
|
|
else sortState = { key, dir: 'asc' };
|
|
renderTable();
|
|
});
|
|
});
|
|
|
|
// Clickable summary pills act as status filters.
|
|
document.querySelectorAll('.filter-pill').forEach(pill => {
|
|
pill.addEventListener('click', () => {
|
|
statusFilter = pill.dataset.status;
|
|
document.querySelectorAll('.filter-pill').forEach(p => p.classList.toggle('active', p === pill));
|
|
renderTable();
|
|
});
|
|
});
|
|
|
|
// Auto-refresh ticker: keeps the "updated Xs ago" label fresh and reloads today's
|
|
// data every AUTO_REFRESH_SEC while auto-refresh is on and today is in view.
|
|
setInterval(() => {
|
|
updateLiveIndicator();
|
|
updateHealth();
|
|
if (document.getElementById('auto-refresh').checked
|
|
&& isViewingToday()
|
|
&& lastLoadedAt
|
|
&& (Date.now() - lastLoadedAt) >= AUTO_REFRESH_SEC * 1000) {
|
|
loadData();
|
|
}
|
|
}, 1000);
|
|
|
|
window.addEventListener('load', async () => {
|
|
restoreState();
|
|
await loadControllerList();
|
|
const sel = document.getElementById('controller-filter');
|
|
if (pendingController && controllersCache.some(c => c.id === pendingController)) {
|
|
sel.value = pendingController;
|
|
}
|
|
await loadData();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|