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)
|
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
|
**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
|
multi-selected group. View an on-screen pivot (users × dates) with per-person
|
||||||
long-format CSV. Days with no badge-in are reported as **absent**, so it doubles
|
on-time / late / absent totals, or download a long-format CSV. Days with no
|
||||||
as an attendance report. (06/19/26)
|
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) |
|
| **Date picker** | Choose which day to view (defaults to the browser's local date) |
|
||||||
| **Badged in by** | Set your on-time cutoff (HH:MM) |
|
| **Badged in by** | Set your on-time cutoff (HH:MM) |
|
||||||
| **Controller** | Filter the table to one controller, or show All |
|
| **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) |
|
| **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 |
|
| **Sync Users** | Pull latest users from every enabled controller |
|
||||||
| **🚫 Filtered** | Open the filtered-tenants modal to review and unhide |
|
| **🚫 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) |
|
| **📊 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 |
|
| Column | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **#** | Row number |
|
| **#** | Row number (within the current sort/filter) |
|
||||||
| **Name** | Resolved display name from UniFi Access |
|
| **Name** | Resolved display name from UniFi Access |
|
||||||
| **Source** | Controller this badge event came from |
|
| **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 |
|
| **Latest Badge In** | Most recent entry — shows *"— same"* if only one badge event |
|
||||||
| **Actor ID** | First 8 characters of the UniFi user UUID |
|
| **Actor ID** | First 8 characters of the UniFi user UUID |
|
||||||
| **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff |
|
| **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
|
> 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
|
> 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
|
### 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
|
- On narrow screens, the attendance table automatically changes into stacked
|
||||||
row cards with labels for each field, so all columns remain visible without
|
row cards with labels for each field, so all columns remain visible without
|
||||||
sideways scrolling.
|
sideways scrolling.
|
||||||
@@ -289,7 +322,8 @@ In the report modal:
|
|||||||
- **Run Report** renders an on-screen **pivot**: one row per selected user, one
|
- **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
|
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
|
(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
|
- **Export CSV** downloads the same data in **long format** — one row per
|
||||||
user-per-day (`Name, Date, Weekday, First In, Status, Sources`) — ideal for
|
user-per-day (`Name, Date, Weekday, First In, Status, Sources`) — ideal for
|
||||||
Excel or Sheets.
|
Excel or Sheets.
|
||||||
@@ -313,10 +347,11 @@ git pull
|
|||||||
/usr/bin/docker compose up -d --build
|
/usr/bin/docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The SQLite database in `./data/` persists across rebuilds. On first start after
|
The SQLite database in `./data/` persists across rebuilds. Schema changes (such
|
||||||
upgrading from a single-controller install, existing badge events are
|
as the `last_event_at` column added for ingestion health) are applied
|
||||||
automatically attached to the seeded "Default" controller — nothing to migrate
|
automatically on boot. On first start after upgrading from a single-controller
|
||||||
by hand.
|
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/<controller_id>` | webhook body | Receives UniFi Access webhook for that controller |
|
||||||
| `POST` | `/api/unifi-access` | webhook body | Legacy alias — routes to the oldest 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/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/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/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 |
|
| `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 |
|
| `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 |
|
| `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/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) |
|
| `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 |
|
| `PATCH` | `/api/controllers/<id>` | `name?`, `enabled?` | Rename or enable/disable a controller |
|
||||||
| `DELETE` | `/api/controllers/<id>` | — | Remove a controller (deletes webhook + its events) |
|
| `DELETE` | `/api/controllers/<id>` | — | Remove a controller (deletes webhook + its events) |
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ def init_db():
|
|||||||
webhook_id TEXT NOT NULL DEFAULT '',
|
webhook_id TEXT NOT NULL DEFAULT '',
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL,
|
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"
|
"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(
|
db.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS persons (
|
CREATE TABLE IF NOT EXISTS persons (
|
||||||
@@ -314,6 +319,7 @@ def controller_to_dict(row):
|
|||||||
"enabled": bool(row["enabled"]),
|
"enabled": bool(row["enabled"]),
|
||||||
"has_webhook": bool(row["webhook_id"]),
|
"has_webhook": bool(row["webhook_id"]),
|
||||||
"last_sync_at": row["last_sync_at"],
|
"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)
|
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
|
# 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 (?, ?, ?, ?)",
|
"INSERT INTO badge_events (controller_id, actor_id, ts, date) VALUES (?, ?, ?, ?)",
|
||||||
(controller_id, actor, ts_str, date),
|
(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()
|
db.commit()
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
|
|||||||
+530
-114
@@ -25,6 +25,12 @@
|
|||||||
padding: 24px 24px 32px;
|
padding: 24px 24px 32px;
|
||||||
}
|
}
|
||||||
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; gap: 16px; }
|
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; }
|
.title-block { display: flex; flex-direction: column; gap: 4px; }
|
||||||
h1 { font-size: 1.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); }
|
h1 { font-size: 1.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); }
|
||||||
.subtitle { font-size: 0.9rem; color: var(--muted); }
|
.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)); }
|
.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)); }
|
.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; }
|
.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); }
|
.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 { width: 9px; height: 9px; border-radius: 50%; }
|
||||||
.dot.on { background: var(--success); box-shadow: 0 0 10px rgba(46,204,113,0.7); }
|
.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.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.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 {
|
.table-card {
|
||||||
background: linear-gradient(135deg, rgba(17,17,19,0.98), rgba(8,8,10,0.98));
|
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;
|
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)); }
|
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, 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 { 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:last-child td { border-bottom: none; }
|
||||||
tbody tr:hover { background: rgba(212,175,55,0.04); }
|
tbody tr:hover { background: rgba(212,175,55,0.04); }
|
||||||
.name-cell { font-weight: 500; color: var(--text); }
|
.name-cell { font-weight: 500; color: var(--text); }
|
||||||
@@ -83,7 +103,20 @@
|
|||||||
}
|
}
|
||||||
.align-center { text-align: center; }
|
.align-center { text-align: center; }
|
||||||
.time-first { color: var(--text); font-weight: 500; }
|
.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; }
|
.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; }
|
.same-badge { color: #555; font-size: 0.82rem; font-style: italic; }
|
||||||
.status-chip {
|
.status-chip {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
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; }
|
.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 { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; }
|
||||||
.empty-state span { color: var(--gold-soft); }
|
.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 td { opacity: 0.45; }
|
||||||
tr.row-filtered .name-cell::after {
|
tr.row-filtered .name-cell::after {
|
||||||
@@ -163,6 +199,14 @@
|
|||||||
table.pivot td.cell-on { color: #c9f7dc; }
|
table.pivot td.cell-on { color: #c9f7dc; }
|
||||||
table.pivot td.cell-absent { color: var(--muted); }
|
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.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; }
|
.report-summary { font-size: 0.78rem; color: var(--muted); margin: 10px 0; }
|
||||||
|
|
||||||
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
||||||
@@ -323,7 +367,13 @@
|
|||||||
<h1>Building Access</h1>
|
<h1>Building Access</h1>
|
||||||
<div class="subtitle">Daily badge-in attendance — powered by UniFi Access</div>
|
<div class="subtitle">Daily badge-in attendance — powered by UniFi Access</div>
|
||||||
</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>
|
</header>
|
||||||
|
|
||||||
<section class="controls">
|
<section class="controls">
|
||||||
@@ -339,12 +389,24 @@
|
|||||||
<label for="controller-filter">Controller</label>
|
<label for="controller-filter">Controller</label>
|
||||||
<select id="controller-filter"><option value="">All</option></select>
|
<select id="controller-filter"><option value="">All</option></select>
|
||||||
</div>
|
</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">
|
<div class="control-group">
|
||||||
<label class="show-filtered-toggle" for="show-filtered">
|
<label class="show-filtered-toggle" for="show-filtered">
|
||||||
<input type="checkbox" id="show-filtered"> Show filtered
|
<input type="checkbox" id="show-filtered"> Show filtered
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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="spacer"></div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button id="today-btn" class="small-btn">Today</button>
|
||||||
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button id="refresh-btn">↻ Refresh</button>
|
<button id="refresh-btn">↻ Refresh</button>
|
||||||
<button class="sync-btn" id="sync-btn">↻ Sync Users</button>
|
<button class="sync-btn" id="sync-btn">↻ Sync Users</button>
|
||||||
@@ -357,9 +419,14 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="summary-row">
|
<section class="summary-row">
|
||||||
<div class="summary-pill"><div class="dot on"></div><span id="on-time-count">0</span> on time</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>
|
||||||
<div class="summary-pill"><div class="dot off"></div><span id="late-count">0</span> late</div>
|
<button class="summary-pill filter-pill" data-status="LATE"><div class="dot off"></div><span id="late-count">0</span> late</button>
|
||||||
<div class="summary-pill"><div class="dot all"></div><span id="total-count">0</span> total</div>
|
<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>
|
||||||
|
|
||||||
<section class="table-card">
|
<section class="table-card">
|
||||||
@@ -367,12 +434,12 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Name</th>
|
<th class="sortable" data-sort="name">Name<span class="sort-arrow">▲</span></th>
|
||||||
<th>Source</th>
|
<th>Source</th>
|
||||||
<th>First Badge In</th>
|
<th class="sortable" data-sort="first_ts">First Badge In<span class="sort-arrow">▲</span></th>
|
||||||
<th>Latest Badge In</th>
|
<th class="sortable" data-sort="latest_ts">Latest Badge In<span class="sort-arrow">▲</span></th>
|
||||||
<th>Actor ID</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>
|
<th class="align-center">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -552,6 +619,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Generic confirmation modal -->
|
||||||
<div class="modal-overlay" id="confirm-modal">
|
<div class="modal-overlay" id="confirm-modal">
|
||||||
<div class="modal danger" role="dialog" aria-modal="true" aria-labelledby="confirm-title" tabindex="-1">
|
<div class="modal danger" role="dialog" aria-modal="true" aria-labelledby="confirm-title" tabindex="-1">
|
||||||
@@ -725,53 +806,182 @@
|
|||||||
if (e.key === 'Escape') closeTopModal();
|
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 sel = document.getElementById('controller-filter');
|
||||||
const prev = sel.value;
|
const prev = sel.value;
|
||||||
try {
|
sel.innerHTML = '<option value="">All controllers</option>' +
|
||||||
const items = await apiFetch('/api/controllers');
|
controllersCache.map(c => `<option value="${c.id}">${escapeHtml(c.name)}${c.enabled ? '' : ' (disabled)'}</option>`).join('');
|
||||||
sel.innerHTML = '<option value="">All controllers</option>' +
|
if (prev && controllersCache.some(c => c.id === prev)) sel.value = prev;
|
||||||
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 [];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadControllerList() {
|
||||||
const date = document.getElementById('date').value || isoToday();
|
await fetchControllers();
|
||||||
const cutoff = document.getElementById('cutoff').value || '09:00';
|
renderControllerDropdown();
|
||||||
const controllerId = document.getElementById('controller-filter').value;
|
updateHealth();
|
||||||
const showFiltered = document.getElementById('show-filtered').checked;
|
return controllersCache;
|
||||||
const params = new URLSearchParams({ date, cutoff });
|
}
|
||||||
if (controllerId) params.set('controller_id', controllerId);
|
|
||||||
if (showFiltered) params.set('include_filtered', '1');
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
data = await apiFetch('/api/first-badge-status?' + params.toString());
|
const date = document.getElementById('date').value || isoToday();
|
||||||
} catch (e) {
|
const cutoff = document.getElementById('cutoff').value || '09:00';
|
||||||
showToast(`Could not load data: ${e.message}`, true);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById('table-body');
|
if (!currentRows.length) {
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
if (!data.length) {
|
|
||||||
tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No badge-in records for this day.</td></tr>';
|
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 => {
|
updateSortIndicators();
|
||||||
document.getElementById(id).textContent = '0';
|
return;
|
||||||
});
|
}
|
||||||
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) => {
|
if (!rows.length) {
|
||||||
const tr = document.createElement('tr');
|
tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No rows match the current filters.</td></tr>';
|
||||||
if (row.filtered) tr.classList.add('row-filtered');
|
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');
|
const numTd = document.createElement('td');
|
||||||
numTd.className = 'muted-cell';
|
numTd.className = 'muted-cell';
|
||||||
@@ -788,39 +998,49 @@
|
|||||||
const sourceTd = document.createElement('td');
|
const sourceTd = document.createElement('td');
|
||||||
sourceTd.dataset.label = 'Source';
|
sourceTd.dataset.label = 'Source';
|
||||||
const sourceWrap = document.createElement('span');
|
const sourceWrap = document.createElement('span');
|
||||||
sourceWrap.className = 'source-list';
|
sourceWrap.className = 'source-list';
|
||||||
const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—'];
|
const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—'];
|
||||||
sources.forEach(s => {
|
sources.forEach(s => {
|
||||||
const chip = document.createElement('span');
|
const chip = document.createElement('span');
|
||||||
chip.className = 'source-chip';
|
chip.className = 'source-chip';
|
||||||
chip.textContent = s;
|
chip.textContent = s;
|
||||||
sourceWrap.appendChild(chip);
|
sourceWrap.appendChild(chip);
|
||||||
});
|
});
|
||||||
sourceTd.appendChild(sourceWrap);
|
sourceTd.appendChild(sourceWrap);
|
||||||
if (row.merged) {
|
if (row.merged) {
|
||||||
const pill = document.createElement('span');
|
const pill = document.createElement('span');
|
||||||
pill.className = 'merged-pill';
|
pill.className = 'merged-pill';
|
||||||
pill.textContent = 'MERGED';
|
pill.textContent = 'MERGED';
|
||||||
sourceTd.appendChild(pill);
|
sourceTd.appendChild(pill);
|
||||||
}
|
}
|
||||||
tr.appendChild(sourceTd);
|
tr.appendChild(sourceTd);
|
||||||
|
|
||||||
const firstTd = document.createElement('td');
|
const firstTd = document.createElement('td');
|
||||||
firstTd.className = 'time-first';
|
|
||||||
firstTd.dataset.label = 'First Badge In';
|
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);
|
tr.appendChild(firstTd);
|
||||||
|
|
||||||
const latestTd = document.createElement('td');
|
const latestTd = document.createElement('td');
|
||||||
latestTd.dataset.label = 'Latest Badge In';
|
latestTd.dataset.label = 'Latest Badge In';
|
||||||
if (!row.latest_ts) {
|
if (!row.latest_ts) {
|
||||||
latestTd.className = 'same-badge';
|
latestTd.className = 'same-badge';
|
||||||
latestTd.textContent = '— same';
|
latestTd.textContent = '— same';
|
||||||
} else {
|
} else {
|
||||||
latestTd.className = 'time-latest';
|
latestTd.className = 'time-latest';
|
||||||
latestTd.textContent = row.latest_ts;
|
latestTd.textContent = row.latest_ts;
|
||||||
}
|
}
|
||||||
tr.appendChild(latestTd);
|
tr.appendChild(latestTd);
|
||||||
|
|
||||||
const idTd = document.createElement('td');
|
const idTd = document.createElement('td');
|
||||||
idTd.className = 'muted-cell';
|
idTd.className = 'muted-cell';
|
||||||
@@ -831,50 +1051,103 @@
|
|||||||
const statusTd = document.createElement('td');
|
const statusTd = document.createElement('td');
|
||||||
statusTd.className = 'align-center';
|
statusTd.className = 'align-center';
|
||||||
statusTd.dataset.label = 'Status';
|
statusTd.dataset.label = 'Status';
|
||||||
const statusChip = document.createElement('div');
|
const statusChip = document.createElement('div');
|
||||||
const isOnTime = row.status === 'ON TIME';
|
const isOnTime = row.status === 'ON TIME';
|
||||||
statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
|
statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
|
||||||
statusChip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
|
statusChip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
|
||||||
statusTd.appendChild(statusChip);
|
statusTd.appendChild(statusChip);
|
||||||
tr.appendChild(statusTd);
|
tr.appendChild(statusTd);
|
||||||
|
|
||||||
const actionTd = document.createElement('td');
|
const actionTd = document.createElement('td');
|
||||||
actionTd.className = 'align-center';
|
actionTd.className = 'align-center';
|
||||||
actionTd.dataset.label = 'Actions';
|
actionTd.dataset.label = 'Actions';
|
||||||
const actionGroup = document.createElement('div');
|
const actionGroup = document.createElement('div');
|
||||||
actionGroup.className = 'row-action-group';
|
actionGroup.className = 'row-action-group';
|
||||||
|
|
||||||
const hideBtn = document.createElement('button');
|
const timesBtn = document.createElement('button');
|
||||||
hideBtn.className = 'row-action-btn hide-btn' + (row.filtered ? ' unhide' : '');
|
timesBtn.className = 'row-action-btn times-btn';
|
||||||
hideBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
|
timesBtn.textContent = 'Times';
|
||||||
hideBtn.dataset.personId = row.person_id || '';
|
timesBtn.dataset.personId = row.person_id || '';
|
||||||
hideBtn.dataset.controllerId = row.controller_id || '';
|
timesBtn.dataset.controllerId = row.controller_id || '';
|
||||||
hideBtn.dataset.actorId = row.actor_id || '';
|
timesBtn.dataset.actorId = row.actor_id || '';
|
||||||
hideBtn.dataset.filtered = row.filtered ? '1' : '0';
|
timesBtn.dataset.name = row.name || '';
|
||||||
hideBtn.dataset.name = row.name || '';
|
actionGroup.appendChild(timesBtn);
|
||||||
actionGroup.appendChild(hideBtn);
|
|
||||||
|
|
||||||
const mergeBtn = document.createElement('button');
|
const hideBtn = document.createElement('button');
|
||||||
mergeBtn.className = 'row-action-btn merge merge-btn';
|
hideBtn.className = 'row-action-btn hide-btn' + (row.filtered ? ' unhide' : '');
|
||||||
mergeBtn.textContent = row.merged ? 'Manage' : 'Merge';
|
hideBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
|
||||||
mergeBtn.dataset.personId = row.person_id || '';
|
hideBtn.dataset.personId = row.person_id || '';
|
||||||
mergeBtn.dataset.controllerId = row.controller_id || '';
|
hideBtn.dataset.controllerId = row.controller_id || '';
|
||||||
mergeBtn.dataset.actorId = row.actor_id || '';
|
hideBtn.dataset.actorId = row.actor_id || '';
|
||||||
mergeBtn.dataset.name = row.name || '';
|
hideBtn.dataset.filtered = row.filtered ? '1' : '0';
|
||||||
actionGroup.appendChild(mergeBtn);
|
hideBtn.dataset.name = row.name || '';
|
||||||
|
actionGroup.appendChild(hideBtn);
|
||||||
|
|
||||||
actionTd.appendChild(actionGroup);
|
const mergeBtn = document.createElement('button');
|
||||||
tr.appendChild(actionTd);
|
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);
|
actionTd.appendChild(actionGroup);
|
||||||
isOnTime ? onTime++ : late++;
|
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) {
|
async function setActorFiltered(controllerId, actorId, filtered) {
|
||||||
if (!controllerId || !actorId) {
|
if (!controllerId || !actorId) {
|
||||||
showToast('Missing controller or actor id', true);
|
showToast('Missing controller or actor id', true);
|
||||||
@@ -906,6 +1179,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('table-body').addEventListener('click', async e => {
|
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 hideBtn = e.target.closest('.hide-btn');
|
||||||
const mergeBtn = e.target.closest('.merge-btn');
|
const mergeBtn = e.target.closest('.merge-btn');
|
||||||
|
|
||||||
@@ -1024,7 +1317,7 @@
|
|||||||
${escapeHtml(c.name)}
|
${escapeHtml(c.name)}
|
||||||
${c.enabled ? '' : '<span class="disabled-tag">(disabled)</span>'}
|
${c.enabled ? '' : '<span class="disabled-tag">(disabled)</span>'}
|
||||||
</div>
|
</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>
|
||||||
<div class="ctrl-actions">
|
<div class="ctrl-actions">
|
||||||
<button class="small-btn sync-btn" data-act="test" data-id="${c.id}">Test</button>
|
<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 head = '<th class="name-col">Name</th>' + data.dates.map(d => {
|
||||||
const label = d.slice(5); // MM-DD
|
const label = d.slice(5); // MM-DD
|
||||||
return `<th class="${isWeekend(d) ? 'weekend' : ''}">${label}</th>`;
|
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 => {
|
const body = data.subjects.map(s => {
|
||||||
|
let sOn = 0, sLate = 0, sAbsent = 0;
|
||||||
const cells = data.dates.map(d => {
|
const cells = data.dates.map(d => {
|
||||||
const r = byKey[s.key] && byKey[s.key][d];
|
const r = byKey[s.key] && byKey[s.key][d];
|
||||||
const wknd = isWeekend(d) ? ' weekend' : '';
|
const wknd = isWeekend(d) ? ' weekend' : '';
|
||||||
if (!r || r.status === 'ABSENT')
|
if (!r || r.status === 'ABSENT') {
|
||||||
|
sAbsent++;
|
||||||
return `<td class="cell-absent${wknd}">—</td>`;
|
return `<td class="cell-absent${wknd}">—</td>`;
|
||||||
|
}
|
||||||
|
if (r.status === 'LATE') sLate++; else sOn++;
|
||||||
const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on';
|
const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on';
|
||||||
return `<td class="${cls}${wknd}">${escapeHtml(r.first_ts)}</td>`;
|
return `<td class="${cls}${wknd}">${escapeHtml(r.first_ts)}</td>`;
|
||||||
}).join('');
|
}).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('');
|
}).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 =
|
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() {
|
function openReportModal() {
|
||||||
@@ -1666,20 +1980,122 @@
|
|||||||
renderReportSubjects();
|
renderReportSubjects();
|
||||||
});
|
});
|
||||||
document.getElementById('report-run-btn').addEventListener('click', runReport);
|
document.getElementById('report-run-btn').addEventListener('click', runReport);
|
||||||
document.getElementById('report-csv-btn').addEventListener('click', () => {
|
document.getElementById('report-csv-btn').addEventListener('click', async () => {
|
||||||
window.location = '/api/report?' + reportParams({ format: 'csv' }).toString();
|
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 --------
|
// -------- Wire up --------
|
||||||
document.getElementById('refresh-btn').addEventListener('click', loadData);
|
document.getElementById('refresh-btn').addEventListener('click', loadData);
|
||||||
document.getElementById('sync-btn').addEventListener('click', syncUsers);
|
document.getElementById('sync-btn').addEventListener('click', syncUsers);
|
||||||
document.getElementById('controller-filter').addEventListener('change', loadData);
|
document.getElementById('controller-filter').addEventListener('change', () => { saveState(); loadData(); });
|
||||||
document.getElementById('show-filtered').addEventListener('change', 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 () => {
|
window.addEventListener('load', async () => {
|
||||||
const dateInput = document.getElementById('date');
|
restoreState();
|
||||||
if (!dateInput.value) dateInput.value = isoToday();
|
|
||||||
await loadControllerList();
|
await loadControllerList();
|
||||||
|
const sel = document.getElementById('controller-filter');
|
||||||
|
if (pendingController && controllersCache.some(c => c.id === pendingController)) {
|
||||||
|
sel.value = pendingController;
|
||||||
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user