filtering tenants
Build and Push Docker Image / build (push) Successful in 22s

This commit is contained in:
2026-05-28 15:01:22 -05:00
parent 0ad30aa6ce
commit a963bd6e31
3 changed files with 303 additions and 14 deletions
+161 -5
View File
@@ -96,6 +96,32 @@
.empty-state { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; }
.empty-state span { color: var(--gold-soft); }
tr.row-filtered td { opacity: 0.45; }
tr.row-filtered .name-cell::after {
content: 'FILTERED'; margin-left: 8px; font-size: 0.65rem; letter-spacing: 0.1em;
color: var(--warn); border: 1px solid rgba(243,156,18,0.5);
padding: 1px 6px; border-radius: 999px; vertical-align: middle;
}
.row-action-btn {
padding: 4px 10px; font-size: 0.7rem; letter-spacing: 0.08em;
border-radius: 999px; border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.04); color: var(--text); cursor: pointer;
text-transform: uppercase;
}
.row-action-btn:hover { border-color: rgba(212,175,55,0.6); }
.row-action-btn.unhide { border-color: rgba(46,204,113,0.6); color: #c9f7dc; }
.show-filtered-toggle {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.78rem; color: var(--muted);
letter-spacing: 0.08em; text-transform: uppercase;
}
.show-filtered-toggle input { min-width: 0; width: auto; margin: 0; }
.filtered-btn {
border-color: rgba(243,156,18,0.6);
background: radial-gradient(circle at top, rgba(243,156,18,0.18), rgba(2,2,4,0.95));
}
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
.modal { background: var(--bg-card); border: 1px solid rgba(212,175,55,0.3); border-radius: 16px; padding: 24px; max-width: 720px; width: 92%; box-shadow: 0 24px 60px rgba(0,0,0,0.8); max-height: 90vh; overflow-y: auto; }
@@ -144,7 +170,8 @@
header { flex-direction: column; align-items: flex-start; }
.controls { flex-direction: column; align-items: stretch; }
input, select, button { width: 100%; }
th:nth-child(5), td:nth-child(5) { display: none; }
th:nth-child(5), td:nth-child(5),
th:nth-child(6), td:nth-child(6) { display: none; }
.form-grid { grid-template-columns: 1fr; }
.ctrl-row { grid-template-columns: 1fr; }
.ctrl-actions { justify-content: flex-start; }
@@ -174,10 +201,16 @@
<label for="controller-filter">Controller</label>
<select id="controller-filter"><option value="">All</option></select>
</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="spacer"></div>
<div class="control-group">
<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="controllers-btn" id="open-controllers-btn">&#9881; Controllers</button>
<button class="reset-btn" id="reset-btn">&#x2715; Reset Day</button>
</div>
@@ -200,10 +233,11 @@
<th>Latest Badge In</th>
<th>Actor ID</th>
<th class="align-center">Status</th>
<th class="align-center">Actions</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="7" class="empty-state">No data yet. <span>Badge into a door</span> and press Refresh.</td></tr>
<tr><td colspan="8" class="empty-state">No data yet. <span>Badge into a door</span> and press Refresh.</td></tr>
</tbody>
</table>
</section>
@@ -265,6 +299,23 @@
</div>
</div>
<!-- Filtered tenants modal -->
<div class="modal-overlay" id="filtered-modal">
<div class="modal">
<h2>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>
<div class="toast" id="toast"></div>
<script>
@@ -297,9 +348,11 @@
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 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 (controllerId) params.set('controller_id', controllerId);
if (showFiltered) params.set('include_filtered', '1');
let data;
try {
@@ -314,7 +367,7 @@
tbody.innerHTML = '';
if (!data.length) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No badge-in records for this day.</td></tr>';
tbody.innerHTML = '<tr><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';
});
@@ -325,6 +378,7 @@
data.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';
@@ -372,6 +426,18 @@
statusTd.appendChild(statusChip);
tr.appendChild(statusTd);
const actionTd = document.createElement('td');
actionTd.className = 'align-center';
const actionBtn = document.createElement('button');
actionBtn.className = 'row-action-btn' + (row.filtered ? ' unhide' : '');
actionBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
actionBtn.dataset.controllerId = row.controller_id || '';
actionBtn.dataset.actorId = row.actor_id || '';
actionBtn.dataset.filtered = row.filtered ? '1' : '0';
actionBtn.dataset.name = row.name || '';
actionTd.appendChild(actionBtn);
tr.appendChild(actionTd);
tbody.appendChild(tr);
isOnTime ? onTime++ : late++;
});
@@ -381,6 +447,36 @@
document.getElementById('total-count').textContent = onTime + late;
}
async function setActorFiltered(controllerId, actorId, filtered) {
if (!controllerId || !actorId) {
showToast('Missing controller or actor id', true);
return false;
}
const r = await fetch(`/api/users/${controllerId}/${actorId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filtered }),
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
showToast(`Failed: ${j.error || r.status}`, true);
return false;
}
return true;
}
document.getElementById('table-body').addEventListener('click', async e => {
const btn = e.target.closest('.row-action-btn');
if (!btn) return;
const wasFiltered = btn.dataset.filtered === '1';
btn.disabled = true;
const ok = await setActorFiltered(btn.dataset.controllerId, btn.dataset.actorId, !wasFiltered);
btn.disabled = false;
if (!ok) return;
showToast(wasFiltered ? `Unhid ${btn.dataset.name}` : `Hid ${btn.dataset.name}`);
await loadData();
});
async function syncUsers() {
const btn = document.getElementById('sync-btn');
const orig = btn.innerHTML;
@@ -591,10 +687,70 @@
}
});
// -------- Filtered tenants modal --------
async function renderFiltered() {
const list = document.getElementById('filtered-list');
list.innerHTML = '<div class="empty-state">Loading…</div>';
let items = [];
try {
const res = await fetch('/api/users?filtered=1');
items = await res.json();
} 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)} &middot; ${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 () => {
document.getElementById('filtered-modal').classList.add('open');
await renderFiltered();
});
document.getElementById('filtered-close').addEventListener('click', () => {
document.getElementById('filtered-modal').classList.remove('open');
});
document.getElementById('filtered-modal').addEventListener('click', e => {
if (e.target === document.getElementById('filtered-modal'))
document.getElementById('filtered-modal').classList.remove('open');
});
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();
});
// -------- 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);
window.addEventListener('load', async () => {
const dateInput = document.getElementById('date');