added reporting feature
Build and Push Docker Image / build (push) Successful in 22s

This commit is contained in:
Jason Stedwell
2026-06-19 09:10:34 -05:00
parent 98793fbecf
commit cdca5557d1
3 changed files with 507 additions and 3 deletions
+251
View File
@@ -125,6 +125,45 @@
border-color: rgba(100,180,255,0.6);
background: radial-gradient(circle at top, rgba(100,180,255,0.18), rgba(2,2,4,0.95));
}
.report-btn {
border-color: rgba(46,204,113,0.6);
background: radial-gradient(circle at top, rgba(46,204,113,0.18), rgba(2,2,4,0.95));
}
/* 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; gap: 6px; }
.report-controls input { min-width: 130px; }
.subject-picker { display: flex; flex-direction: column; gap: 6px;
max-height: 220px; overflow-y: auto; margin: 6px 0 4px;
border: 1px solid var(--border); border-radius: 10px; padding: 8px; }
.subject-row {
display: flex; align-items: center; gap: 10px; padding: 5px 8px;
border-radius: 8px; cursor: pointer; font-size: 0.85rem;
}
.subject-row:hover { background: rgba(255,255,255,0.03); }
.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; }
.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(--bg-card); z-index: 1;
font-weight: 500; color: var(--text);
}
table.pivot td.cell-late { color: #ffd6d7; }
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); }
.report-summary { font-size: 0.78rem; color: var(--muted); margin: 10px 0; }
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.merged-pill {
@@ -248,6 +287,7 @@
<button id="refresh-btn">&#8635; Refresh</button>
<button class="sync-btn" id="sync-btn">&#8635; Sync Users</button>
<button class="filtered-btn" id="open-filtered-btn">&#128683; Filtered</button>
<button class="report-btn" id="open-report-btn">&#128202; Report</button>
<button class="people-btn" id="open-people-btn">&#128101; People</button>
<button class="controllers-btn" id="open-controllers-btn">&#9881; Controllers</button>
<button class="reset-btn" id="reset-btn">&#x2715; Reset Day</button>
@@ -404,6 +444,52 @@
</div>
</div>
<!-- Report modal -->
<div class="modal-overlay" id="report-modal">
<div class="modal wide">
<h2>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">&#11015; 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>
<div class="toast" id="toast"></div>
<script>
@@ -1206,6 +1292,171 @@
}
});
// -------- 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 {
const res = await fetch('/api/report/subjects?' + p.toString());
reportSubjects = await res.json();
} catch {
reportSubjects = [];
showToast('Could not load users', true);
}
renderReportSubjects();
}
async function runReport() {
const errEl = document.getElementById('report-error');
errEl.textContent = '';
const res = await fetch('/api/report?' + reportParams().toString());
let data;
try { data = await res.json(); } catch { data = null; }
if (!res.ok || !data) {
errEl.textContent = (data && data.error) || 'Report failed';
return;
}
renderPivot(data);
}
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('');
const body = data.subjects.map(s => {
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')
return `<td class="cell-absent${wknd}">—</td>`;
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>`;
}).join('');
result.innerHTML =
`<table class="pivot"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
function openReportModal() {
const today = isoToday();
const start = new Date(); start.setDate(start.getDate() - 6);
document.getElementById('report-start').value = start.toISOString().slice(0, 10);
document.getElementById('report-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();
document.getElementById('report-modal').classList.add('open');
loadReportSubjects();
}
document.getElementById('open-report-btn').addEventListener('click', openReportModal);
document.getElementById('report-close').addEventListener('click', () => {
document.getElementById('report-modal').classList.remove('open');
});
document.getElementById('report-modal').addEventListener('click', e => {
if (e.target.id === 'report-modal') e.currentTarget.classList.remove('open');
});
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', () => {
window.location = '/api/report?' + reportParams({ format: 'csv' }).toString();
});
// -------- Wire up --------
document.getElementById('refresh-btn').addEventListener('click', loadData);
document.getElementById('sync-btn').addEventListener('click', syncUsers);