This commit is contained in:
@@ -17,6 +17,11 @@ Merge those identities into a single "person" so the dashboard computes ONE
|
|||||||
first-badge time per human, not per badge UUID — no more false LATE warnings
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -182,6 +187,7 @@ Per-controller actions in the modal:
|
|||||||
| **Refresh** | Reload the table |
|
| **Refresh** | Reload the table |
|
||||||
| **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) |
|
||||||
| **👥 People** | Manage merged identities and review auto-suggested merges |
|
| **👥 People** | Manage merged identities and review auto-suggested merges |
|
||||||
| **⚙ Controllers** | Add / manage controllers |
|
| **⚙ Controllers** | Add / manage controllers |
|
||||||
| **Reset Day** | Delete all badge records for the selected date (respects the Controller filter — testing only) |
|
| **Reset Day** | Delete all badge records for the selected date (respects the Controller filter — testing only) |
|
||||||
@@ -255,6 +261,38 @@ filtered — unhiding restores their full history with no gaps.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Reporting over a date range
|
||||||
|
|
||||||
|
The live table only shows one day. To pull first badge-in times across a range —
|
||||||
|
for a single person or a whole group — click **📊 Report** in the header.
|
||||||
|
|
||||||
|
In the report modal:
|
||||||
|
|
||||||
|
- **Start / End** — the inclusive date range (capped at 366 days).
|
||||||
|
- **On-time cutoff** — the HH:MM threshold for ON TIME vs LATE (defaults to the
|
||||||
|
cutoff set in the main controls).
|
||||||
|
- **Users** — a searchable checklist of every report subject: each merged
|
||||||
|
**person** plus every unmerged actor. Pick one for an individual report, or
|
||||||
|
several for a group. **Select all** and **Clear** act on the current search.
|
||||||
|
- **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 **—**.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
The report respects the header's **Controller** filter and **Show filtered**
|
||||||
|
toggle, and it uses the same identity-merging logic as the live table, so a
|
||||||
|
merged person reports **one** first-badge time per day computed across all their
|
||||||
|
controllers. Days a selected user never badged in are reported as **ABSENT**,
|
||||||
|
making the export a complete attendance record for the range.
|
||||||
|
|
||||||
|
> Reporting reads existing `badge_events` only — no schema changes, no extra
|
||||||
|
> storage. First-badge-of-day is just `MIN(ts)` per `(person, date)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Updating from GitHub
|
## Updating from GitHub
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -281,6 +319,8 @@ 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/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 |
|
| `GET` | `/api/users` | `controller_id?`, `filtered?` | List cached actors with their filtered flag |
|
||||||
| `PATCH` | `/api/users/<controller_id>/<actor_id>` | `filtered` (bool) | Hide / unhide an actor from the attendance table |
|
| `PATCH` | `/api/users/<controller_id>/<actor_id>` | `filtered` (bool) | Hide / unhide an actor from the attendance table |
|
||||||
| `GET` | `/api/persons` | — | List merged people with their members |
|
| `GET` | `/api/persons` | — | List merged people with their members |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import os, hmac, hashlib, json, logging, uuid, re
|
import os, hmac, hashlib, json, logging, uuid, re, csv, io
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify, Response
|
||||||
import pytz, sqlite3
|
import pytz, sqlite3
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
import requests, urllib3
|
import requests, urllib3
|
||||||
@@ -399,6 +399,219 @@ def first_badge_status():
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reporting — first badge-in times over a date range
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
|
||||||
|
|
||||||
|
def _weekday(date_str):
|
||||||
|
return _WEEKDAYS[datetime.strptime(date_str, "%Y-%m-%d").weekday()]
|
||||||
|
|
||||||
|
|
||||||
|
def _date_series(start, end):
|
||||||
|
"""Inclusive list of YYYY-MM-DD strings from start to end."""
|
||||||
|
d0 = datetime.strptime(start, "%Y-%m-%d").date()
|
||||||
|
d1 = datetime.strptime(end, "%Y-%m-%d").date()
|
||||||
|
days, cur = [], d0
|
||||||
|
while cur <= d1:
|
||||||
|
days.append(cur.isoformat())
|
||||||
|
cur += timedelta(days=1)
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def _build_subject_universe(db, controller_filter, include_filtered):
|
||||||
|
"""The selectable report subjects, keyed by group_key — merged persons plus
|
||||||
|
every unmerged actor. Mirrors the grouping used by /api/first-badge-status."""
|
||||||
|
subjects = {}
|
||||||
|
|
||||||
|
for p in db.execute("SELECT * FROM persons").fetchall():
|
||||||
|
if not include_filtered and p["filtered"]:
|
||||||
|
continue
|
||||||
|
if controller_filter:
|
||||||
|
on_ctrl = db.execute(
|
||||||
|
"SELECT 1 FROM person_members WHERE person_id = ? AND controller_id = ?",
|
||||||
|
(p["id"], controller_filter),
|
||||||
|
).fetchone()
|
||||||
|
if not on_ctrl:
|
||||||
|
continue
|
||||||
|
subjects[p["id"]] = {
|
||||||
|
"key": p["id"],
|
||||||
|
"name": p["display_name"],
|
||||||
|
"controller_name": "Merged",
|
||||||
|
"filtered": bool(p["filtered"]),
|
||||||
|
"merged": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT u.controller_id, u.actor_id, u.full_name,
|
||||||
|
COALESCE(u.filtered, 0) AS filtered,
|
||||||
|
c.name AS controller_name
|
||||||
|
FROM user_cache u
|
||||||
|
LEFT JOIN controllers c ON c.id = u.controller_id
|
||||||
|
LEFT JOIN person_members pm
|
||||||
|
ON pm.controller_id = u.controller_id AND pm.actor_id = u.actor_id
|
||||||
|
WHERE pm.person_id IS NULL
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
if controller_filter:
|
||||||
|
sql += " AND u.controller_id = ?"
|
||||||
|
params.append(controller_filter)
|
||||||
|
if not include_filtered:
|
||||||
|
sql += " AND COALESCE(u.filtered, 0) = 0"
|
||||||
|
for u in db.execute(sql, params).fetchall():
|
||||||
|
key = f"{u['controller_id']}|{u['actor_id']}"
|
||||||
|
subjects[key] = {
|
||||||
|
"key": key,
|
||||||
|
"name": u["full_name"] or f"User {u['actor_id'][:8]}",
|
||||||
|
"controller_name": u["controller_name"] or "—",
|
||||||
|
"filtered": bool(u["filtered"]),
|
||||||
|
"merged": False,
|
||||||
|
}
|
||||||
|
return subjects
|
||||||
|
|
||||||
|
|
||||||
|
def _run_range_query(db, start, end, controller_filter, include_filtered):
|
||||||
|
"""Map of (group_key, date) -> first badge-in for the whole range."""
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
COALESCE(pm.person_id, b.controller_id || '|' || b.actor_id) AS group_key,
|
||||||
|
b.date AS date,
|
||||||
|
MIN(b.ts) AS first_ts,
|
||||||
|
GROUP_CONCAT(DISTINCT c.name) AS sources
|
||||||
|
FROM badge_events b
|
||||||
|
LEFT JOIN person_members pm
|
||||||
|
ON pm.controller_id = b.controller_id AND pm.actor_id = b.actor_id
|
||||||
|
LEFT JOIN persons p ON p.id = pm.person_id
|
||||||
|
LEFT JOIN user_cache u
|
||||||
|
ON u.actor_id = b.actor_id AND u.controller_id = b.controller_id
|
||||||
|
LEFT JOIN controllers c ON c.id = b.controller_id
|
||||||
|
WHERE b.date BETWEEN ? AND ?
|
||||||
|
"""
|
||||||
|
params = [start, end]
|
||||||
|
if controller_filter:
|
||||||
|
sql += " AND b.controller_id = ?"
|
||||||
|
params.append(controller_filter)
|
||||||
|
if not include_filtered:
|
||||||
|
sql += " AND COALESCE(p.filtered, u.filtered, 0) = 0"
|
||||||
|
sql += " GROUP BY group_key, date"
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
for r in db.execute(sql, params).fetchall():
|
||||||
|
out[(r["group_key"], r["date"])] = {
|
||||||
|
"first_ts": r["first_ts"],
|
||||||
|
"sources": [s for s in (r["sources"] or "").split(",") if s],
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/report/subjects")
|
||||||
|
def report_subjects():
|
||||||
|
controller_filter = request.args.get("controller_id", "").strip() or None
|
||||||
|
include_filtered = request.args.get("include_filtered") == "1"
|
||||||
|
with get_db() as db:
|
||||||
|
universe = _build_subject_universe(db, controller_filter, include_filtered)
|
||||||
|
out = sorted(universe.values(), key=lambda s: s["name"].lower())
|
||||||
|
return jsonify([
|
||||||
|
{
|
||||||
|
"key": s["key"],
|
||||||
|
"name": s["name"],
|
||||||
|
"controller_name": s["controller_name"],
|
||||||
|
"merged": s["merged"],
|
||||||
|
"filtered": s["filtered"],
|
||||||
|
}
|
||||||
|
for s in out
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/report")
|
||||||
|
def report():
|
||||||
|
today = datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d")
|
||||||
|
start = request.args.get("start", today)
|
||||||
|
end = request.args.get("end", today)
|
||||||
|
cutoff = request.args.get("cutoff", "09:00")
|
||||||
|
controller_filter = request.args.get("controller_id", "").strip() or None
|
||||||
|
include_filtered = request.args.get("include_filtered") == "1"
|
||||||
|
fmt = request.args.get("format", "json").lower()
|
||||||
|
subjects_arg = request.args.get("subjects", "").strip()
|
||||||
|
|
||||||
|
if not re.match(r"^\d{4}-\d{2}-\d{2}$", start) or not re.match(r"^\d{4}-\d{2}-\d{2}$", end):
|
||||||
|
return jsonify({"error": "start and end must be YYYY-MM-DD"}), 400
|
||||||
|
if start > end:
|
||||||
|
start, end = end, start
|
||||||
|
if not re.match(r"^\d{2}:\d{2}$", cutoff):
|
||||||
|
cutoff = "09:00"
|
||||||
|
cutoff_end = cutoff + ":59"
|
||||||
|
|
||||||
|
try:
|
||||||
|
days = _date_series(start, end)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "invalid date"}), 400
|
||||||
|
if len(days) > 366:
|
||||||
|
return jsonify({"error": "date range too large (max 366 days)"}), 400
|
||||||
|
|
||||||
|
selected_keys = [s for s in subjects_arg.split(",") if s] if subjects_arg else None
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
universe = _build_subject_universe(db, controller_filter, include_filtered)
|
||||||
|
events = _run_range_query(db, start, end, controller_filter, include_filtered)
|
||||||
|
|
||||||
|
if selected_keys:
|
||||||
|
subjects = [universe[k] for k in selected_keys if k in universe]
|
||||||
|
else:
|
||||||
|
subjects = sorted(universe.values(), key=lambda s: s["name"].lower())
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for subj in subjects:
|
||||||
|
for d in days:
|
||||||
|
ev = events.get((subj["key"], d))
|
||||||
|
if ev:
|
||||||
|
first = ev["first_ts"]
|
||||||
|
status = "ON TIME" if first <= cutoff_end else "LATE"
|
||||||
|
sources = ev["sources"]
|
||||||
|
else:
|
||||||
|
first, status, sources = None, "ABSENT", []
|
||||||
|
rows.append({
|
||||||
|
"subject_key": subj["key"],
|
||||||
|
"name": subj["name"],
|
||||||
|
"date": d,
|
||||||
|
"weekday": _weekday(d),
|
||||||
|
"first_ts": first,
|
||||||
|
"status": status,
|
||||||
|
"sources": sources,
|
||||||
|
})
|
||||||
|
|
||||||
|
if fmt == "csv":
|
||||||
|
buf = io.StringIO()
|
||||||
|
w = csv.writer(buf)
|
||||||
|
w.writerow(["Name", "Date", "Weekday", "First In", "Status", "Sources"])
|
||||||
|
for r in rows:
|
||||||
|
w.writerow([
|
||||||
|
r["name"], r["date"], r["weekday"],
|
||||||
|
r["first_ts"] or "", r["status"], "; ".join(r["sources"]),
|
||||||
|
])
|
||||||
|
return Response(
|
||||||
|
buf.getvalue(),
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition":
|
||||||
|
f'attachment; filename="badge-report_{start}_{end}.csv"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"cutoff": cutoff,
|
||||||
|
"dates": days,
|
||||||
|
"subjects": [
|
||||||
|
{"key": s["key"], "name": s["name"], "merged": s["merged"]}
|
||||||
|
for s in subjects
|
||||||
|
],
|
||||||
|
"rows": rows,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Webhook ingestion — per-controller endpoint, plus legacy compat alias
|
# Webhook ingestion — per-controller endpoint, plus legacy compat alias
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -125,6 +125,45 @@
|
|||||||
border-color: rgba(100,180,255,0.6);
|
border-color: rgba(100,180,255,0.6);
|
||||||
background: radial-gradient(circle at top, rgba(100,180,255,0.18), rgba(2,2,4,0.95));
|
background: radial-gradient(circle at top, rgba(100,180,255,0.18), rgba(2,2,4,0.95));
|
||||||
}
|
}
|
||||||
|
.report-btn {
|
||||||
|
border-color: rgba(46,204,113,0.6);
|
||||||
|
background: radial-gradient(circle at top, rgba(46,204,113,0.18), rgba(2,2,4,0.95));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Report modal */
|
||||||
|
.modal.wide { max-width: 1000px; }
|
||||||
|
.report-controls {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.report-controls .control-group { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.report-controls input { min-width: 130px; }
|
||||||
|
.subject-picker { display: flex; flex-direction: column; gap: 6px;
|
||||||
|
max-height: 220px; overflow-y: auto; margin: 6px 0 4px;
|
||||||
|
border: 1px solid var(--border); border-radius: 10px; padding: 8px; }
|
||||||
|
.subject-row {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 5px 8px;
|
||||||
|
border-radius: 8px; cursor: pointer; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.subject-row:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.subject-row input { width: auto; min-width: 0; margin: 0; }
|
||||||
|
.subject-row .s-sub { color: var(--muted); font-size: 0.72rem; margin-left: auto; }
|
||||||
|
.picker-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 6px;
|
||||||
|
font-size: 0.74rem; }
|
||||||
|
.picker-toolbar a { color: var(--blue); cursor: pointer; text-decoration: underline; }
|
||||||
|
.picker-toolbar .count { color: var(--muted); margin-left: auto; }
|
||||||
|
|
||||||
|
.report-result { margin-top: 8px; overflow-x: auto; }
|
||||||
|
table.pivot { font-size: 0.82rem; }
|
||||||
|
table.pivot th, table.pivot td { padding: 8px 10px; white-space: nowrap; text-align: center; }
|
||||||
|
table.pivot th.name-col, table.pivot td.name-col {
|
||||||
|
text-align: left; position: sticky; left: 0; background: var(--bg-card); z-index: 1;
|
||||||
|
font-weight: 500; color: var(--text);
|
||||||
|
}
|
||||||
|
table.pivot td.cell-late { color: #ffd6d7; }
|
||||||
|
table.pivot td.cell-on { color: #c9f7dc; }
|
||||||
|
table.pivot td.cell-absent { color: var(--muted); }
|
||||||
|
table.pivot th.weekend, table.pivot td.weekend { background: rgba(255,255,255,0.02); }
|
||||||
|
.report-summary { font-size: 0.78rem; color: var(--muted); margin: 10px 0; }
|
||||||
|
|
||||||
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
||||||
.merged-pill {
|
.merged-pill {
|
||||||
@@ -248,6 +287,7 @@
|
|||||||
<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>
|
||||||
<button class="filtered-btn" id="open-filtered-btn">🚫 Filtered</button>
|
<button class="filtered-btn" id="open-filtered-btn">🚫 Filtered</button>
|
||||||
|
<button class="report-btn" id="open-report-btn">📊 Report</button>
|
||||||
<button class="people-btn" id="open-people-btn">👥 People</button>
|
<button class="people-btn" id="open-people-btn">👥 People</button>
|
||||||
<button class="controllers-btn" id="open-controllers-btn">⚙ Controllers</button>
|
<button class="controllers-btn" id="open-controllers-btn">⚙ Controllers</button>
|
||||||
<button class="reset-btn" id="reset-btn">✕ Reset Day</button>
|
<button class="reset-btn" id="reset-btn">✕ Reset Day</button>
|
||||||
@@ -404,6 +444,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Report modal -->
|
||||||
|
<div class="modal-overlay" id="report-modal">
|
||||||
|
<div class="modal wide">
|
||||||
|
<h2>First Badge-In Report</h2>
|
||||||
|
<p>First badge-in per day over a date range. Pick one person for an individual
|
||||||
|
report, or several for a group. Days with no badge-in show as <strong>absent</strong>.</p>
|
||||||
|
|
||||||
|
<div class="report-controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="report-start">Start</label>
|
||||||
|
<input type="date" id="report-start">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="report-end">End</label>
|
||||||
|
<input type="date" id="report-end">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="report-cutoff">On-time cutoff</label>
|
||||||
|
<input type="time" id="report-cutoff" value="09:00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-label">Users</div>
|
||||||
|
<input type="text" id="report-search" class="picker-search" placeholder="Search by name…">
|
||||||
|
<div class="picker-toolbar">
|
||||||
|
<a id="report-select-all">Select all</a>
|
||||||
|
<a id="report-select-none">Clear</a>
|
||||||
|
<span class="count" id="report-sel-count">0 selected</span>
|
||||||
|
</div>
|
||||||
|
<div class="subject-picker" id="report-subjects">
|
||||||
|
<div class="empty-state">Loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-error" id="report-error"></div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-cancel" id="report-close">Close</button>
|
||||||
|
<button class="report-btn" id="report-csv-btn">⬇ Export CSV</button>
|
||||||
|
<button id="report-run-btn">Run Report</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-summary" id="report-summary"></div>
|
||||||
|
<div class="report-result" id="report-result"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -1206,6 +1292,171 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------- Report --------
|
||||||
|
let reportSubjects = [];
|
||||||
|
const reportSelected = new Set();
|
||||||
|
|
||||||
|
function reportParams(extra = {}) {
|
||||||
|
const p = new URLSearchParams({
|
||||||
|
start: document.getElementById('report-start').value || isoToday(),
|
||||||
|
end: document.getElementById('report-end').value || isoToday(),
|
||||||
|
cutoff: document.getElementById('report-cutoff').value || '09:00',
|
||||||
|
});
|
||||||
|
const cid = document.getElementById('controller-filter').value;
|
||||||
|
if (cid) p.set('controller_id', cid);
|
||||||
|
if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1');
|
||||||
|
if (reportSelected.size) p.set('subjects', [...reportSelected].join(','));
|
||||||
|
Object.entries(extra).forEach(([k, v]) => p.set(k, v));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReportSelCount() {
|
||||||
|
document.getElementById('report-sel-count').textContent =
|
||||||
|
`${reportSelected.size} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReportSubjects() {
|
||||||
|
const term = document.getElementById('report-search').value.trim().toLowerCase();
|
||||||
|
const box = document.getElementById('report-subjects');
|
||||||
|
const list = reportSubjects.filter(s => s.name.toLowerCase().includes(term));
|
||||||
|
if (!list.length) {
|
||||||
|
box.innerHTML = '<div class="empty-state">No matching users.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = list.map(s => `
|
||||||
|
<label class="subject-row">
|
||||||
|
<input type="checkbox" data-key="${escapeHtml(s.key)}" ${reportSelected.has(s.key) ? 'checked' : ''}>
|
||||||
|
<span>${escapeHtml(s.name)}${s.merged ? ' <span class="merged-pill">MERGED</span>' : ''}</span>
|
||||||
|
<span class="s-sub">${escapeHtml(s.controller_name || '')}</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
updateReportSelCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReportSubjects() {
|
||||||
|
const box = document.getElementById('report-subjects');
|
||||||
|
box.innerHTML = '<div class="empty-state">Loading…</div>';
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
const cid = document.getElementById('controller-filter').value;
|
||||||
|
if (cid) p.set('controller_id', cid);
|
||||||
|
if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/report/subjects?' + p.toString());
|
||||||
|
reportSubjects = await res.json();
|
||||||
|
} catch {
|
||||||
|
reportSubjects = [];
|
||||||
|
showToast('Could not load users', true);
|
||||||
|
}
|
||||||
|
renderReportSubjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runReport() {
|
||||||
|
const errEl = document.getElementById('report-error');
|
||||||
|
errEl.textContent = '';
|
||||||
|
const res = await fetch('/api/report?' + reportParams().toString());
|
||||||
|
let data;
|
||||||
|
try { data = await res.json(); } catch { data = null; }
|
||||||
|
if (!res.ok || !data) {
|
||||||
|
errEl.textContent = (data && data.error) || 'Report failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderPivot(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPivot(data) {
|
||||||
|
const summary = document.getElementById('report-summary');
|
||||||
|
const result = document.getElementById('report-result');
|
||||||
|
if (!data.subjects.length) {
|
||||||
|
summary.textContent = '';
|
||||||
|
result.innerHTML = '<div class="empty-state">No users selected (or none match).</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// index rows by subject_key + date
|
||||||
|
const byKey = {};
|
||||||
|
data.rows.forEach(r => {
|
||||||
|
(byKey[r.subject_key] = byKey[r.subject_key] || {})[r.date] = r;
|
||||||
|
});
|
||||||
|
|
||||||
|
let late = 0, absent = 0, present = 0;
|
||||||
|
data.rows.forEach(r => {
|
||||||
|
if (r.status === 'LATE') late++;
|
||||||
|
else if (r.status === 'ABSENT') absent++;
|
||||||
|
else present++;
|
||||||
|
});
|
||||||
|
summary.textContent =
|
||||||
|
`${data.subjects.length} user(s) · ${data.dates.length} day(s) · ` +
|
||||||
|
`${present} on time · ${late} late · ${absent} absent`;
|
||||||
|
|
||||||
|
const isWeekend = d => { const g = new Date(d + 'T00:00:00').getDay(); return g === 0 || g === 6; };
|
||||||
|
const head = '<th class="name-col">Name</th>' + data.dates.map(d => {
|
||||||
|
const label = d.slice(5); // MM-DD
|
||||||
|
return `<th class="${isWeekend(d) ? 'weekend' : ''}">${label}</th>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const body = data.subjects.map(s => {
|
||||||
|
const cells = data.dates.map(d => {
|
||||||
|
const r = byKey[s.key] && byKey[s.key][d];
|
||||||
|
const wknd = isWeekend(d) ? ' weekend' : '';
|
||||||
|
if (!r || r.status === 'ABSENT')
|
||||||
|
return `<td class="cell-absent${wknd}">—</td>`;
|
||||||
|
const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on';
|
||||||
|
return `<td class="${cls}${wknd}">${escapeHtml(r.first_ts)}</td>`;
|
||||||
|
}).join('');
|
||||||
|
return `<tr><td class="name-col">${escapeHtml(s.name)}</td>${cells}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
result.innerHTML =
|
||||||
|
`<table class="pivot"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReportModal() {
|
||||||
|
const today = isoToday();
|
||||||
|
const start = new Date(); start.setDate(start.getDate() - 6);
|
||||||
|
document.getElementById('report-start').value = start.toISOString().slice(0, 10);
|
||||||
|
document.getElementById('report-end').value = today;
|
||||||
|
document.getElementById('report-cutoff').value =
|
||||||
|
document.getElementById('cutoff').value || '09:00';
|
||||||
|
document.getElementById('report-search').value = '';
|
||||||
|
document.getElementById('report-error').textContent = '';
|
||||||
|
document.getElementById('report-summary').textContent = '';
|
||||||
|
document.getElementById('report-result').innerHTML = '';
|
||||||
|
reportSelected.clear();
|
||||||
|
updateReportSelCount();
|
||||||
|
document.getElementById('report-modal').classList.add('open');
|
||||||
|
loadReportSubjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('open-report-btn').addEventListener('click', openReportModal);
|
||||||
|
document.getElementById('report-close').addEventListener('click', () => {
|
||||||
|
document.getElementById('report-modal').classList.remove('open');
|
||||||
|
});
|
||||||
|
document.getElementById('report-modal').addEventListener('click', e => {
|
||||||
|
if (e.target.id === 'report-modal') e.currentTarget.classList.remove('open');
|
||||||
|
});
|
||||||
|
document.getElementById('report-search').addEventListener('input', renderReportSubjects);
|
||||||
|
document.getElementById('report-subjects').addEventListener('change', e => {
|
||||||
|
const cb = e.target.closest('input[type=checkbox]');
|
||||||
|
if (!cb) return;
|
||||||
|
if (cb.checked) reportSelected.add(cb.dataset.key);
|
||||||
|
else reportSelected.delete(cb.dataset.key);
|
||||||
|
updateReportSelCount();
|
||||||
|
});
|
||||||
|
document.getElementById('report-select-all').addEventListener('click', () => {
|
||||||
|
const term = document.getElementById('report-search').value.trim().toLowerCase();
|
||||||
|
reportSubjects.filter(s => s.name.toLowerCase().includes(term))
|
||||||
|
.forEach(s => reportSelected.add(s.key));
|
||||||
|
renderReportSubjects();
|
||||||
|
});
|
||||||
|
document.getElementById('report-select-none').addEventListener('click', () => {
|
||||||
|
reportSelected.clear();
|
||||||
|
renderReportSubjects();
|
||||||
|
});
|
||||||
|
document.getElementById('report-run-btn').addEventListener('click', runReport);
|
||||||
|
document.getElementById('report-csv-btn').addEventListener('click', () => {
|
||||||
|
window.location = '/api/report?' + reportParams({ format: 'csv' }).toString();
|
||||||
|
});
|
||||||
|
|
||||||
// -------- Wire up --------
|
// -------- 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user