Add live auto-refresh, search/sort, health, reporting totals, and badge-times modal
Build and Push Docker Image / build (push) Successful in 6s
Build and Push Docker Image / build (push) Successful in 6s
Dashboard usability pass: - Auto-refresh: poll today every 45s with a live "updated Xs ago" indicator and toggle; auto-pauses on past dates. - Table search, click-to-sort column headers, and clickable summary pills that filter by on-time / late. - View state (date, cutoff, controller, filters) persists to localStorage and the URL for reloads, bookmarking, and sharing; adds a Today button. - First-run onboarding CTA when no controllers exist, plus a header health pill and per-controller last-event time driven by a new last_event_at column (stamped on webhook ingest; auto-migrated on boot). - Report pivot gains per-person on-time/late/absent totals and an All row; CSV export now downloads via fetch/blob so failures surface as errors. - New badge-in times modal (GET /api/badge-events) listing every badge-in for a person on a day — across all identities for merged people. Docs updated to cover all of the above. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+530
-114
@@ -25,6 +25,12 @@
|
||||
padding: 24px 24px 32px;
|
||||
}
|
||||
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; gap: 16px; }
|
||||
.header-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.health-pill {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--bg-card); border-radius: 999px; padding: 6px 14px;
|
||||
border: 1px solid var(--border); color: var(--muted); font-size: 0.78rem;
|
||||
}
|
||||
.title-block { display: flex; flex-direction: column; gap: 4px; }
|
||||
h1 { font-size: 1.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); }
|
||||
.subtitle { font-size: 0.9rem; color: var(--muted); }
|
||||
@@ -57,12 +63,21 @@
|
||||
.controllers-btn{ border-color: rgba(160,120,255,0.6); background: radial-gradient(circle at top, rgba(160,120,255,0.18), rgba(2,2,4,0.95)); }
|
||||
.reset-btn { border-color: rgba(255,100,100,0.6); background: radial-gradient(circle at top, rgba(255,80,80,0.18), rgba(2,2,4,0.95)); }
|
||||
.small-btn { padding: 6px 12px; font-size: 0.75rem; }
|
||||
.summary-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; font-size: 0.85rem; }
|
||||
.summary-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; font-size: 0.85rem; align-items: center; }
|
||||
.summary-pill { display: inline-flex; align-items: center; gap: 8px; background: var(--bg-card); border-radius: 999px; padding: 6px 14px; border: 1px solid var(--border); color: var(--muted); }
|
||||
button.summary-pill {
|
||||
cursor: pointer; text-transform: none; letter-spacing: normal;
|
||||
font-size: 0.85rem; transition: border-color 0.1s, background 0.1s;
|
||||
}
|
||||
button.summary-pill:hover { border-color: rgba(212,175,55,0.5); transform: none; box-shadow: none; }
|
||||
button.summary-pill.active { border-color: var(--gold-soft); color: var(--text); background: rgba(212,175,55,0.08); }
|
||||
.live-indicator { font-size: 0.76rem; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; }
|
||||
.dot.on { background: var(--success); box-shadow: 0 0 10px rgba(46,204,113,0.7); }
|
||||
.dot.off { background: var(--danger); box-shadow: 0 0 10px rgba(255,77,79,0.7); }
|
||||
.dot.all { background: var(--gold-soft); box-shadow: 0 0 10px rgba(184,150,48,0.5); }
|
||||
.dot.stale { background: var(--warn); box-shadow: 0 0 10px rgba(243,156,18,0.6); }
|
||||
.dot.idle { background: var(--muted); box-shadow: none; }
|
||||
.table-card {
|
||||
background: linear-gradient(135deg, rgba(17,17,19,0.98), rgba(8,8,10,0.98));
|
||||
border-radius: 14px; border: 1px solid rgba(255,255,255,0.04); overflow: hidden;
|
||||
@@ -71,6 +86,11 @@
|
||||
thead { background: radial-gradient(circle at top left, rgba(212,175,55,0.22), rgba(10,10,12,0.9)); }
|
||||
th, td { padding: 10px 16px; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.04); white-space: nowrap; }
|
||||
th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); }
|
||||
th.sortable { cursor: pointer; user-select: none; }
|
||||
th.sortable:hover { color: var(--gold-soft); }
|
||||
th.sortable .sort-arrow { opacity: 0; margin-left: 4px; font-size: 0.7rem; }
|
||||
th.sortable.sorted-asc .sort-arrow,
|
||||
th.sortable.sorted-desc .sort-arrow { opacity: 1; color: var(--gold); }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: rgba(212,175,55,0.04); }
|
||||
.name-cell { font-weight: 500; color: var(--text); }
|
||||
@@ -83,7 +103,20 @@
|
||||
}
|
||||
.align-center { text-align: center; }
|
||||
.time-first { color: var(--text); font-weight: 500; }
|
||||
.time-first.clickable { cursor: pointer; text-decoration: underline dotted rgba(212,175,55,0.5); text-underline-offset: 3px; }
|
||||
.time-first.clickable:hover { color: var(--gold); }
|
||||
.time-latest { color: var(--muted); font-size: 0.85rem; }
|
||||
.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: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
}
|
||||
.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); font-weight: 600; }
|
||||
.time-entry .t-tag { font-size: 0.62rem; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--gold-soft); border: 1px solid rgba(212,175,55,0.4); border-radius: 999px; padding: 1px 7px; margin-left: 6px; }
|
||||
.same-badge { color: #555; font-size: 0.82rem; font-style: italic; }
|
||||
.status-chip {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -95,6 +128,9 @@
|
||||
.chip-dot { width: 7px; height: 7px; 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-soft); }
|
||||
.onboard-cta { padding: 36px 16px; text-align: center; }
|
||||
.onboard-cta h3 { color: var(--gold); font-size: 1.05rem; margin-bottom: 8px; letter-spacing: 0.04em; }
|
||||
.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 {
|
||||
@@ -163,6 +199,14 @@
|
||||
table.pivot td.cell-on { color: #c9f7dc; }
|
||||
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 rgba(255,255,255,0.12);
|
||||
font-variant-numeric: tabular-nums; font-size: 0.78rem;
|
||||
}
|
||||
table.pivot td.tot-late { color: #ffb3b5; }
|
||||
table.pivot td.tot-absent { color: var(--warn); }
|
||||
table.pivot tr.totals-row td { border-top: 1px solid rgba(255,255,255,0.14); font-weight: 600; color: var(--text); }
|
||||
table.pivot tr.totals-row td.name-col { color: var(--gold-soft); }
|
||||
.report-summary { font-size: 0.78rem; color: var(--muted); margin: 10px 0; }
|
||||
|
||||
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
||||
@@ -323,7 +367,13 @@
|
||||
<h1>Building Access</h1>
|
||||
<div class="subtitle">Daily badge-in attendance — powered by UniFi Access</div>
|
||||
</div>
|
||||
<div class="badge">LIVE ATTENDANCE DASHBOARD</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 class="badge">LIVE ATTENDANCE DASHBOARD</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="controls">
|
||||
@@ -339,12 +389,24 @@
|
||||
<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>
|
||||
@@ -357,9 +419,14 @@
|
||||
</section>
|
||||
|
||||
<section class="summary-row">
|
||||
<div class="summary-pill"><div class="dot on"></div><span id="on-time-count">0</span> on time</div>
|
||||
<div class="summary-pill"><div class="dot off"></div><span id="late-count">0</span> late</div>
|
||||
<div class="summary-pill"><div class="dot all"></div><span id="total-count">0</span> total</div>
|
||||
<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">
|
||||
@@ -367,12 +434,12 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th class="sortable" data-sort="name">Name<span class="sort-arrow">▲</span></th>
|
||||
<th>Source</th>
|
||||
<th>First Badge In</th>
|
||||
<th>Latest Badge In</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">Status</th>
|
||||
<th class="align-center sortable" data-sort="status">Status<span class="sort-arrow">▲</span></th>
|
||||
<th class="align-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -552,6 +619,20 @@
|
||||
</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">
|
||||
@@ -725,53 +806,182 @@
|
||||
if (e.key === 'Escape') closeTopModal();
|
||||
});
|
||||
|
||||
async function loadControllerList() {
|
||||
// ----- 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;
|
||||
try {
|
||||
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;
|
||||
return items;
|
||||
} catch {
|
||||
return [];
|
||||
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 loadData() {
|
||||
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');
|
||||
async function loadControllerList() {
|
||||
await fetchControllers();
|
||||
renderControllerDropdown();
|
||||
updateHealth();
|
||||
return controllersCache;
|
||||
}
|
||||
|
||||
let data;
|
||||
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 {
|
||||
data = await apiFetch('/api/first-badge-status?' + params.toString());
|
||||
} catch (e) {
|
||||
showToast(`Could not load data: ${e.message}`, true);
|
||||
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;
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('table-body');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!data.length) {
|
||||
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>';
|
||||
['on-time-count','late-count','total-count'].forEach(id => {
|
||||
document.getElementById(id).textContent = '0';
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateSortIndicators();
|
||||
return;
|
||||
}
|
||||
|
||||
let onTime = 0, late = 0;
|
||||
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);
|
||||
|
||||
data.forEach((row, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
if (row.filtered) tr.classList.add('row-filtered');
|
||||
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';
|
||||
@@ -788,39 +998,49 @@
|
||||
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);
|
||||
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.className = 'time-first';
|
||||
firstTd.dataset.label = 'First Badge In';
|
||||
firstTd.textContent = row.first_ts || '—';
|
||||
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);
|
||||
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';
|
||||
@@ -831,50 +1051,103 @@
|
||||
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 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 actionGroup = document.createElement('div');
|
||||
actionGroup.className = 'row-action-group';
|
||||
|
||||
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 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 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);
|
||||
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);
|
||||
|
||||
actionTd.appendChild(actionGroup);
|
||||
tr.appendChild(actionTd);
|
||||
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);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
isOnTime ? onTime++ : late++;
|
||||
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');
|
||||
});
|
||||
|
||||
document.getElementById('on-time-count').textContent = onTime;
|
||||
document.getElementById('late-count').textContent = late;
|
||||
document.getElementById('total-count').textContent = onTime + late;
|
||||
}
|
||||
|
||||
async function setActorFiltered(controllerId, actorId, filtered) {
|
||||
if (!controllerId || !actorId) {
|
||||
showToast('Missing controller or actor id', true);
|
||||
@@ -906,6 +1179,26 @@
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -1024,7 +1317,7 @@
|
||||
${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)}${c.has_webhook ? '' : ' · <span style="color:var(--danger)">no webhook</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>
|
||||
@@ -1605,22 +1898,43 @@
|
||||
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('');
|
||||
}).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')
|
||||
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('');
|
||||
return `<tr><td class="name-col">${escapeHtml(s.name)}</td>${cells}</tr>`;
|
||||
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}</tbody></table>`;
|
||||
`<table class="pivot"><thead><tr>${head}</tr></thead><tbody>${body}${totalsRow}</tbody></table>`;
|
||||
}
|
||||
|
||||
function openReportModal() {
|
||||
@@ -1666,20 +1980,122 @@
|
||||
renderReportSubjects();
|
||||
});
|
||||
document.getElementById('report-run-btn').addEventListener('click', runReport);
|
||||
document.getElementById('report-csv-btn').addEventListener('click', () => {
|
||||
window.location = '/api/report?' + reportParams({ format: 'csv' }).toString();
|
||||
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', loadData);
|
||||
document.getElementById('show-filtered').addEventListener('change', loadData);
|
||||
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 () => {
|
||||
const dateInput = document.getElementById('date');
|
||||
if (!dateInput.value) dateInput.value = isoToday();
|
||||
restoreState();
|
||||
await loadControllerList();
|
||||
const sel = document.getElementById('controller-filter');
|
||||
if (pendingController && controllersCache.some(c => c.id === pendingController)) {
|
||||
sel.value = pendingController;
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user