This commit is contained in:
@@ -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">↻ 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>
|
||||
@@ -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">⬇ 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);
|
||||
|
||||
Reference in New Issue
Block a user