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:
@@ -18,9 +18,18 @@ first-badge time per human, not per badge UUID — no more false LATE warnings
|
||||
for staff who badge into multiple buildings before the cutoff. (05/28/26)
|
||||
|
||||
**Reporting:** export first badge-in times over a date range for one person or a
|
||||
multi-selected group. View an on-screen pivot (users × dates) or download a
|
||||
long-format CSV. Days with no badge-in are reported as **absent**, so it doubles
|
||||
as an attendance report. (06/19/26)
|
||||
multi-selected group. View an on-screen pivot (users × dates) with per-person
|
||||
on-time / late / absent totals, or download a long-format CSV. Days with no
|
||||
badge-in are reported as **absent**, so it doubles as an attendance report. (06/19/26)
|
||||
|
||||
**Live & searchable:** the table auto-refreshes on a 45-second interval with a
|
||||
live "updated Xs ago" indicator, plus a header **health pill** that shows when the
|
||||
last badge event actually arrived — so a dead webhook is obvious at a glance.
|
||||
Filter rows by name, click any column header to sort, and click the summary pills
|
||||
to show only on-time or late people. View settings (date, cutoff, controller,
|
||||
filters) persist across reloads and are encoded in the URL for bookmarking and
|
||||
sharing. Click any person's badge time — or their **Times** button — to see
|
||||
**every** badge-in for that day, not just first and latest. (07/01/26)
|
||||
|
||||
---
|
||||
|
||||
@@ -183,8 +192,11 @@ Per-controller actions in the modal:
|
||||
| **Date picker** | Choose which day to view (defaults to the browser's local date) |
|
||||
| **Badged in by** | Set your on-time cutoff (HH:MM) |
|
||||
| **Controller** | Filter the table to one controller, or show All |
|
||||
| **Search** | Filter the visible rows by name (client-side, instant) |
|
||||
| **Show filtered** | Include filtered tenants in the table (dimmed and tagged) |
|
||||
| **Refresh** | Reload the table |
|
||||
| **Auto-refresh** | Toggle live 45-second polling of the current day (auto-pauses when viewing a past date) |
|
||||
| **Today** | Jump the date picker back to today |
|
||||
| **Refresh** | Reload the table now |
|
||||
| **Sync Users** | Pull latest users from every enabled controller |
|
||||
| **🚫 Filtered** | Open the filtered-tenants modal to review and unhide |
|
||||
| **📊 Report** | Open the date-range report builder (first badge-in per day, per user, CSV export) |
|
||||
@@ -196,14 +208,18 @@ Per-controller actions in the modal:
|
||||
|
||||
| Column | Description |
|
||||
|---|---|
|
||||
| **#** | Row number |
|
||||
| **#** | Row number (within the current sort/filter) |
|
||||
| **Name** | Resolved display name from UniFi Access |
|
||||
| **Source** | Controller this badge event came from |
|
||||
| **First Badge In** | Earliest door entry for the day — never changes once set |
|
||||
| **First Badge In** | Earliest door entry for the day — never changes once set. **Click it** (or the Times button) to see the full list of the day's badge-ins |
|
||||
| **Latest Badge In** | Most recent entry — shows *"— same"* if only one badge event |
|
||||
| **Actor ID** | First 8 characters of the UniFi user UUID |
|
||||
| **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff |
|
||||
| **Actions** | **Hide** filters this person out of future views; **Merge** joins two badge identities so they count as one human |
|
||||
| **Actions** | **Times** opens every badge-in for that person on the selected day; **Hide** filters this person out of future views; **Merge** joins two badge identities so they count as one human |
|
||||
|
||||
> Click a sortable header (Name, First/Latest Badge In, Status) to sort by that
|
||||
> column; click again to reverse. Click the **on time** / **late** / **total**
|
||||
> summary pills to show only that group.
|
||||
|
||||
> Once two identities are merged, the Source column shows a chip for every
|
||||
> controller the person badged into that day, plus a "MERGED" pill so it's
|
||||
@@ -211,6 +227,23 @@ Per-controller actions in the modal:
|
||||
|
||||
### Interface notes
|
||||
|
||||
- **Auto-refresh & live status.** When viewing today with Auto-refresh on, the
|
||||
table reloads every 45 seconds. The pill in the summary row shows *"updated Xs
|
||||
ago"* (or *"paused"* / *"viewing past day"* when polling is off).
|
||||
- **Ingestion health.** The pill in the header shows the freshest badge event
|
||||
across all enabled controllers — *"last badge 3m ago"* (green), *"no events…"*
|
||||
(amber, stale), or *"webhook not registered"* — so a broken webhook is caught
|
||||
without digging into the Controllers modal (which also shows per-controller
|
||||
last-event time).
|
||||
- **Badge-in times.** Clicking a person's First-badge time or their **Times**
|
||||
button lists every badge-in for that day, oldest first, tagged *first* and
|
||||
*latest* and labelled with the source controller. For merged people the list
|
||||
spans all their identities.
|
||||
- **View persistence & sharing.** Date, cutoff, controller, and the filter
|
||||
toggles are saved to the browser and mirrored into the URL, so reloading keeps
|
||||
your view and you can bookmark or share a link to a specific day/controller.
|
||||
- **First-run onboarding.** With no controllers configured, the table shows an
|
||||
"Add a Controller" call-to-action instead of an empty grid.
|
||||
- On narrow screens, the attendance table automatically changes into stacked
|
||||
row cards with labels for each field, so all columns remain visible without
|
||||
sideways scrolling.
|
||||
@@ -289,7 +322,8 @@ In the report modal:
|
||||
- **Run Report** renders an on-screen **pivot**: one row per selected user, one
|
||||
column per date, each cell showing that day's first badge-in time
|
||||
(green = on time, red = late). Weekends are shaded and days with no badge-in
|
||||
show as **—**.
|
||||
show as **—**. Trailing **On time / Late / Absent** columns total each person
|
||||
over the range, and a final **All** row totals every column.
|
||||
- **Export CSV** downloads the same data in **long format** — one row per
|
||||
user-per-day (`Name, Date, Weekday, First In, Status, Sources`) — ideal for
|
||||
Excel or Sheets.
|
||||
@@ -313,10 +347,11 @@ git pull
|
||||
/usr/bin/docker compose up -d --build
|
||||
```
|
||||
|
||||
The SQLite database in `./data/` persists across rebuilds. On first start after
|
||||
upgrading from a single-controller install, existing badge events are
|
||||
automatically attached to the seeded "Default" controller — nothing to migrate
|
||||
by hand.
|
||||
The SQLite database in `./data/` persists across rebuilds. Schema changes (such
|
||||
as the `last_event_at` column added for ingestion health) are applied
|
||||
automatically on boot. On first start after upgrading from a single-controller
|
||||
install, existing badge events are automatically attached to the seeded
|
||||
"Default" controller — nothing to migrate by hand.
|
||||
|
||||
---
|
||||
|
||||
@@ -331,6 +366,7 @@ reverse proxy with auth in front of it.
|
||||
| `POST` | `/api/unifi-access/<controller_id>` | webhook body | Receives UniFi Access webhook for that controller |
|
||||
| `POST` | `/api/unifi-access` | webhook body | Legacy alias — routes to the oldest controller |
|
||||
| `GET` | `/api/first-badge-status` | `date`, `cutoff`, `controller_id?`, `include_filtered?` | Returns first + latest badge per user (filtered tenants hidden unless `include_filtered=1`) |
|
||||
| `GET` | `/api/badge-events` | `date`, and either `person_id` **or** `controller_id`+`actor_id` | Every badge-in for one subject on one day, oldest first (for a merged person, across all their identities) |
|
||||
| `GET` | `/api/report/subjects` | `controller_id?`, `include_filtered?` | List selectable report subjects (merged persons + unmerged actors), each with a stable `key` |
|
||||
| `GET` | `/api/report` | `start`, `end`, `cutoff?`, `subjects?`, `controller_id?`, `include_filtered?`, `format?` | First badge-in per subject per day over the range. JSON (pivot-shaped) by default; `format=csv` returns a long-format download. Missing days reported as `ABSENT`. `subjects` is a comma-separated list of subject `key`s (empty = all) |
|
||||
| `GET` | `/api/users` | `controller_id?`, `filtered?` | List cached actors with their filtered flag |
|
||||
@@ -342,7 +378,7 @@ reverse proxy with auth in front of it.
|
||||
| `POST` | `/api/persons/<id>/members` | `controller_id`, `actor_id` | Add another identity to an existing person |
|
||||
| `DELETE` | `/api/persons/<id>/members/<cid>/<aid>` | — | Split one identity off; dissolves the person if it was the last member |
|
||||
| `GET` | `/api/persons/suggestions` | — | Exact full-name matches across controllers, excluding already-merged actors |
|
||||
| `GET` | `/api/controllers` | — | List configured controllers |
|
||||
| `GET` | `/api/controllers` | — | List configured controllers (includes `last_sync_at` and `last_event_at` for health) |
|
||||
| `POST` | `/api/controllers` | `name`, `host`, `port`, `api_token` | Add a controller (also registers webhook) |
|
||||
| `PATCH` | `/api/controllers/<id>` | `name?`, `enabled?` | Rename or enable/disable a controller |
|
||||
| `DELETE` | `/api/controllers/<id>` | — | Remove a controller (deletes webhook + its events) |
|
||||
|
||||
@@ -59,7 +59,8 @@ def init_db():
|
||||
webhook_id TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
last_sync_at TEXT
|
||||
last_sync_at TEXT,
|
||||
last_event_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -104,6 +105,10 @@ def init_db():
|
||||
"ALTER TABLE user_cache ADD COLUMN filtered INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
# Migrate legacy controllers that pre-date the last_event_at column.
|
||||
if not _column_exists(db, "controllers", "last_event_at"):
|
||||
db.execute("ALTER TABLE controllers ADD COLUMN last_event_at TEXT")
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS persons (
|
||||
@@ -314,6 +319,7 @@ def controller_to_dict(row):
|
||||
"enabled": bool(row["enabled"]),
|
||||
"has_webhook": bool(row["webhook_id"]),
|
||||
"last_sync_at": row["last_sync_at"],
|
||||
"last_event_at": row["last_event_at"],
|
||||
}
|
||||
|
||||
|
||||
@@ -399,6 +405,56 @@ def first_badge_status():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/badge-events")
|
||||
def badge_events_for_subject():
|
||||
"""All badge-in events for one subject on one day, oldest first. A subject is
|
||||
either a merged person (person_id) or a single actor (controller_id+actor_id)."""
|
||||
date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
|
||||
person_id = request.args.get("person_id", "").strip() or None
|
||||
ctrl_id = request.args.get("controller_id", "").strip() or None
|
||||
actor_id = request.args.get("actor_id", "").strip() or None
|
||||
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date):
|
||||
return jsonify({"error": "date must be YYYY-MM-DD"}), 400
|
||||
|
||||
with get_db() as db:
|
||||
if person_id:
|
||||
members = db.execute(
|
||||
"SELECT controller_id, actor_id FROM person_members WHERE person_id = ?",
|
||||
(person_id,),
|
||||
).fetchall()
|
||||
pairs = [(m["controller_id"], m["actor_id"]) for m in members]
|
||||
elif ctrl_id and actor_id:
|
||||
pairs = [(ctrl_id, actor_id)]
|
||||
else:
|
||||
return jsonify({"error": "person_id, or controller_id + actor_id, required"}), 400
|
||||
|
||||
if not pairs:
|
||||
return jsonify([])
|
||||
|
||||
placeholders = ",".join(["(?,?)"] * len(pairs))
|
||||
sql = f"""
|
||||
SELECT b.ts, b.controller_id, b.actor_id, c.name AS source
|
||||
FROM badge_events b
|
||||
LEFT JOIN controllers c ON c.id = b.controller_id
|
||||
WHERE b.date = ?
|
||||
AND (b.controller_id, b.actor_id) IN ({placeholders})
|
||||
ORDER BY b.ts ASC
|
||||
"""
|
||||
params = [date] + [v for pair in pairs for v in pair]
|
||||
rows = db.execute(sql, params).fetchall()
|
||||
|
||||
return jsonify([
|
||||
{
|
||||
"ts": r["ts"],
|
||||
"controller_id": r["controller_id"],
|
||||
"actor_id": r["actor_id"],
|
||||
"source": r["source"] or "—",
|
||||
}
|
||||
for r in rows
|
||||
])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reporting — first badge-in times over a date range
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -678,6 +734,10 @@ def _ingest_webhook(controller_id):
|
||||
"INSERT INTO badge_events (controller_id, actor_id, ts, date) VALUES (?, ?, ?, ?)",
|
||||
(controller_id, actor, ts_str, date),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE controllers SET last_event_at = ? WHERE id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), controller_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
log.info(
|
||||
|
||||
+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