From 4115b90a481b78d75eaa4bdf5c0e5ea809957598 Mon Sep 17 00:00:00 2001 From: jasonMPM Date: Wed, 4 Mar 2026 19:53:39 -0600 Subject: [PATCH] Add files via upload --- README.md | 162 +++++------------ app.py | 140 +++++++++------ static/index.html | 432 +++++++++++++++++++++++++++------------------- 3 files changed, 389 insertions(+), 345 deletions(-) diff --git a/README.md b/README.md index 9712c90..f4e5fc2 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,21 @@ -# UniFi Access Badge-In Dashboard (V2) +# UniFi Access Badge-In Dashboard (V3) A Dockerised Flask + SQLite web app that receives UniFi Access `access.door.unlock` webhooks, -resolves UUID actor IDs to real user names via the UniFi Access API, -and displays a modern dark dashboard with on-time / late status per person. +resolves UUIDs to real user names via the UniFi Access API, and displays a modern dark +attendance dashboard with on-time / late status, first + latest badge times, and a test reset button. --- ## Features -- Receives `access.door.unlock` webhooks from either the **UniFi Access developer API** or the legacy **Alarm Manager** format. -- Resolves blank `user_name` UUIDs to real names by querying the UniFi Access REST API on a schedule (every 6 hours) and caching results in SQLite. -- Manual **Sync Users** button in the UI triggers an immediate cache refresh. -- Dark theme with gold accents, green ON TIME / red LATE status chips. -- Date picker + configurable "badged in by" cutoff time. -- Fully Dockerised — single container, persisted SQLite volume. +- Receives webhooks from the UniFi Access developer API or legacy Alarm Manager format. +- Resolves UUIDs to real names via the UniFi Access REST API (cached in SQLite, refreshed every 6 hrs). +- **First badge in** column — never overwritten by subsequent badges. +- **Latest badge in** column — shows the most recent entry that day. +- **Sync Users** button — manually refreshes the user name cache. +- **Reset Day** button — confirmation modal deletes all records for the selected date (testing only). +- Green ON TIME / Red LATE status chips based on a configurable cutoff time. +- Fully Dockerised — single container, persistent SQLite volume. --- @@ -21,50 +23,33 @@ and displays a modern dark dashboard with on-time / late status per person. ``` . -├── app.py # Flask application +├── app.py ├── requirements.txt ├── Dockerfile ├── docker-compose.yml -├── .env.example # Copy to .env and fill in your values +├── .env.example ├── .gitignore └── static/ - └── index.html # Dashboard UI + └── index.html ``` --- -## Step 1 — Generate a UniFi Access API token +## Setup -1. Open your **UniFi OS** web interface. -2. Navigate to **UniFi Access → Settings → Integrations** (or the **Developer API** section). -3. Create a new **API Key** (Bearer Token). Copy it — you will need it below. +### 1. Generate a UniFi Access API token -> Use a dedicated read-only admin account to generate the token if possible. +1. Open UniFi OS → UniFi Access → Settings → Integrations / Developer API. +2. Create a new API Key (Bearer Token) and copy it. ---- - -## Step 2 — Create your .env file - -Copy `.env.example` to `.env` and fill in your values: +### 2. Create your .env file ```bash cp .env.example .env +# edit .env and fill in UNIFI_HOST and UNIFI_API_TOKEN ``` -```dotenv -UNIFI_HOST=192.168.1.1 # IP of your UniFi OS controller -UNIFI_API_TOKEN=YOUR_TOKEN_HERE -TZ=America/Chicago -DB_PATH=/data/dashboard.db -``` - -> **Never commit `.env` to git.** It is listed in `.gitignore`. - ---- - -## Step 3 — Register the webhook with UniFi Access - -Run this once from any machine on the same LAN as your controller (replace placeholders): +### 3. Register the webhook with UniFi Access (run once) ```bash curl -k -X POST "https://192.168.1.1:45/api1/webhooks/endpoints" \ @@ -78,97 +63,26 @@ curl -k -X POST "https://192.168.1.1:45/api1/webhooks/endpoints" \ }' ``` -Verify it was registered: +Verify registration: ```bash curl -k -X GET "https://192.168.1.1:45/api1/webhooks/endpoints" \ -H "Authorization: Bearer YOUR_TOKEN_HERE" ``` -You should see your webhook listed with a unique `id`. - -> **Note:** The UniFi Access API runs on **port 45** over HTTPS with a self-signed cert. -> Always pass `-k` to curl, or `verify=False` in Python requests. - ---- - -## Step 4 — Deploy on Unraid - -### Clone from GitHub and build locally - -SSH into your Unraid server: +### 4. Deploy on Unraid ```bash cd /mnt/user/appdata git clone https://github.com//.git unifi-access-dashboard cd unifi-access-dashboard -cp .env.example .env -nano .env # fill in UNIFI_HOST and UNIFI_API_TOKEN +cp .env.example .env && nano .env docker compose up -d --build ``` -The app is now running on **port 8000**. +Open: `http://:8000/` -### Unraid GUI (Docker tab — Add Container) - -If you prefer using the GUI after pushing an image to Docker Hub: - -```bash -# On your workstation: -docker build -t /unifi-access-dashboard:latest . -docker push /unifi-access-dashboard:latest -``` - -Then in Unraid → Docker → Add Container: - -| Field | Value | -|---|---| -| Name | unifi-access-dashboard | -| Repository | `/unifi-access-dashboard:latest` | -| Port | Host `8000` → Container `8000` | -| Path | Host `/mnt/user/appdata/unifi-access-dashboard/data` → Container `/data` | -| Env: UNIFI_HOST | `192.168.1.x` | -| Env: UNIFI_API_TOKEN | `your-token` | -| Env: TZ | `America/Chicago` | - ---- - -## Step 5 — Open the dashboard - -Browse to: - -``` -http://:8000/ -``` - -- Choose a **date** and set the **"Badged in by"** cutoff time (e.g. `09:00`). -- Click **Refresh** to load the day's first badge-in per person. -- Green chip = on time. Red chip = late. -- Click **Sync Users** to immediately pull the latest user list from your UniFi Access controller. - ---- - -## Keeping the user cache fresh - -The app automatically re-syncs users from UniFi Access every **6 hours** in the background. -If you add or rename a badge holder, click **Sync Users** in the dashboard or restart the container. - ---- - -## Troubleshooting - -| Symptom | Cause | Fix | -|---|---|---| -| Names show as `Unknown (UUID…)` | Users not yet cached | Click Sync Users or wait for the 6-hour job | -| Webhook not arriving | Firewall / Docker network | Ensure port 8000 is reachable from the UniFi controller | -| `curl` returns SSL error | Self-signed cert | Add `-k` to bypass; already handled in Python | -| 404 on `/api1/users` | Firmware difference | Try `/api/v1/users`; check your Access app version | -| Duplicate events | Both Alarm Manager and API webhook active | Remove one, or they will both store rows (harmless but duplicates) | -| Container exits | `.env` missing | Ensure `.env` exists and `docker compose up` picks it up | - ---- - -## Updating from GitHub +### 5. Updating from GitHub ```bash cd /mnt/user/appdata/unifi-access-dashboard @@ -178,9 +92,23 @@ docker compose up -d --build --- -## Security notes +## API endpoints -- The `.env` file is excluded from git via `.gitignore`. -- The UniFi API token is never exposed to the frontend. -- Mount `/data` to persistent storage so badge history survives container restarts. -- For external access, place a reverse proxy (Nginx/Traefik) with HTTPS in front. +| Method | Path | Description | +|---|---|---| +| POST | `/api/unifi-access` | Receives webhook from UniFi Access | +| GET | `/api/first-badge-status` | Returns first + latest badge per user for a date | +| GET | `/api/sync-users` | Triggers immediate user cache sync | +| DELETE | `/api/reset-day?date=YYYY-MM-DD` | Deletes all records for the given date | + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Names show as `Unknown (UUID…)` | Users not cached yet | Click Sync Users | +| Webhook not arriving | Firewall / port | Ensure port 8000 reachable from controller | +| SSL error on curl | Self-signed cert | Use `-k` flag | +| 404 on `/api1/users` | Firmware path differs | Try `/api/v1/users` | +| Duplicate events | Both Alarm Manager and API webhooks active | Remove one or deduplicate by event ID | diff --git a/app.py b/app.py index 44fb3fd..d51ad04 100644 --- a/app.py +++ b/app.py @@ -12,8 +12,8 @@ load_dotenv() app = Flask(__name__) -DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db") -UNIFI_HOST = os.environ.get("UNIFI_HOST", "") +DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db") +UNIFI_HOST = os.environ.get("UNIFI_HOST", "") UNIFI_API_TOKEN = os.environ.get("UNIFI_API_TOKEN", "") @@ -30,16 +30,16 @@ def init_db(): conn = get_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS unlocks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - event TEXT NOT NULL DEFAULT 'access.door.unlock', - door_name TEXT, - device_name TEXT, - actor_id TEXT, - actor_name TEXT, + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + event TEXT NOT NULL DEFAULT 'access.door.unlock', + door_name TEXT, + device_name TEXT, + actor_id TEXT, + actor_name TEXT, resolved_name TEXT, - auth_type TEXT, - result TEXT + auth_type TEXT, + result TEXT ); CREATE TABLE IF NOT EXISTS access_users ( @@ -49,11 +49,11 @@ def init_db(): updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); """) - # migration: add resolved_name if upgrading from V1 - try: - conn.execute("ALTER TABLE unlocks ADD COLUMN resolved_name TEXT") - except Exception: - pass + for col in ("resolved_name", "device_name"): + try: + conn.execute(f"ALTER TABLE unlocks ADD COLUMN {col} TEXT") + except Exception: + pass conn.commit() conn.close() @@ -64,13 +64,13 @@ def sync_unifi_users(): if not UNIFI_HOST or not UNIFI_API_TOKEN: print("[UniFi Sync] UNIFI_HOST or UNIFI_API_TOKEN not set — skipping.") return - url = f"https://{UNIFI_HOST}:45/api1/users" + url = f"https://{UNIFI_HOST}:45/api1/users" headers = {"Authorization": f"Bearer {UNIFI_API_TOKEN}"} try: resp = requests.get(url, headers=headers, verify=False, timeout=10) resp.raise_for_status() users = resp.json().get("data", []) - conn = get_db() + conn = get_db() for user in users: conn.execute( """INSERT INTO access_users (id, name, email, updated_at) @@ -92,7 +92,7 @@ def resolve_user_name(user_id: str) -> str: if not user_id: return "Unknown" conn = get_db() - row = conn.execute( + row = conn.execute( "SELECT name FROM access_users WHERE id = ?", (user_id,) ).fetchone() conn.close() @@ -104,36 +104,32 @@ def resolve_user_name(user_id: str) -> str: @app.post("/api/unifi-access") def unifi_access_webhook(): payload = request.get_json(force=True, silent=True) or {} + ts = dt.datetime.utcnow().isoformat(timespec="seconds") + "Z" - ts = dt.datetime.utcnow().isoformat(timespec="seconds") + "Z" - - # ── New developer-API webhook format ────────────────────────────────────── if payload.get("event") == "access.door.unlock": - data = payload.get("data", {}) - actor = data.get("actor", {}) - location = data.get("location", {}) - device = data.get("device", {}) - obj = data.get("object", {}) - - actor_id = actor.get("id", "") - actor_name = actor.get("name") or resolve_user_name(actor_id) - door_name = location.get("name") + data = payload.get("data", {}) + actor = data.get("actor", {}) + location = data.get("location", {}) + device = data.get("device", {}) + obj = data.get("object", {}) + actor_id = actor.get("id", "") + actor_name = actor.get("name") or resolve_user_name(actor_id) + door_name = location.get("name") device_name = device.get("name") - auth_type = obj.get("authentication_type") - result = obj.get("result", "Access Granted") + auth_type = obj.get("authentication_type") + result = obj.get("result", "Access Granted") - # ── Legacy Alarm Manager format ─────────────────────────────────────────── elif "events" in payload: - event = payload["events"][0] - actor_id = event.get("user", "") - actor_name = event.get("user_name") or resolve_user_name(actor_id) - door_name = event.get("location_name") or event.get("location", "Unknown Door") + event = payload["events"][0] + actor_id = event.get("user", "") + actor_name = event.get("user_name") or resolve_user_name(actor_id) + door_name = event.get("location_name") or event.get("location", "Unknown Door") device_name = None - auth_type = event.get("unlock_method_text", "Unknown") - result = "Access Granted" + auth_type = event.get("unlock_method_text", "Unknown") + result = "Access Granted" else: - return "", 204 # unrecognised — silently ignore + return "", 204 conn = get_db() conn.execute( @@ -159,7 +155,8 @@ def first_badge_status(): end = f"{date}T23:59:59Z" conn = get_db() - rows = conn.execute( + # First badge per person + first_rows = conn.execute( """SELECT COALESCE(resolved_name, actor_name, actor_id) AS display_name, actor_id, MIN(ts) AS first_ts @@ -172,29 +169,72 @@ def first_badge_status(): ORDER BY first_ts""", (start, end), ).fetchall() + + # Latest badge per person (may equal first if they only badged once) + latest_rows = conn.execute( + """SELECT actor_id, MAX(ts) AS latest_ts + FROM unlocks + WHERE event = 'access.door.unlock' + AND result = 'Access Granted' + AND ts BETWEEN ? AND ? + AND actor_id IS NOT NULL + GROUP BY actor_id""", + (start, end), + ).fetchall() conn.close() + latest_map = {r["actor_id"]: r["latest_ts"] for r in latest_rows} + result = [] - for r in rows: - t = dt.datetime.fromisoformat(r["first_ts"].replace("Z", "+00:00")) - badge_time = t.strftime("%H:%M") + for r in first_rows: + t_first = dt.datetime.fromisoformat(r["first_ts"].replace("Z", "+00:00")) + first_time = t_first.strftime("%H:%M") + + latest_ts = latest_map.get(r["actor_id"], r["first_ts"]) + t_latest = dt.datetime.fromisoformat(latest_ts.replace("Z", "+00:00")) + latest_time = t_latest.strftime("%H:%M") + result.append({ - "actor_name": r["display_name"], - "actor_id": r["actor_id"], - "first_badge": r["first_ts"], - "badge_time": badge_time, - "on_time": badge_time <= cutoff, + "actor_name": r["display_name"], + "actor_id": r["actor_id"], + "first_badge": r["first_ts"], + "badge_time": first_time, + "latest_badge": latest_ts, + "latest_time": latest_time, + "on_time": first_time <= cutoff, }) return jsonify(result) +# ── Sync users ──────────────────────────────────────────────────────────────── + @app.get("/api/sync-users") def manual_sync(): sync_unifi_users() return jsonify({"status": "ok"}) +# ── Reset day (testing only) ────────────────────────────────────────────────── + +@app.delete("/api/reset-day") +def reset_day(): + """Delete all unlock records for a given date (defaults to today). + Intended for development/testing only. + """ + date = request.args.get("date") or dt.date.today().isoformat() + start = f"{date}T00:00:00Z" + end = f"{date}T23:59:59Z" + conn = get_db() + res = conn.execute( + "DELETE FROM unlocks WHERE ts BETWEEN ? AND ?", (start, end) + ) + conn.commit() + deleted = res.rowcount + conn.close() + return jsonify({"status": "ok", "deleted": deleted, "date": date}) + + # ── Static files ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/static/index.html b/static/index.html index d6be9d9..d6482f8 100644 --- a/static/index.html +++ b/static/index.html @@ -14,6 +14,7 @@ --muted: #888; --danger: #ff4d4f; --success: #2ecc71; + --warn: #f39c12; --border: #222; } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -27,7 +28,7 @@ padding: 32px 16px; } .app-shell { - width: 100%; max-width: 1200px; + width: 100%; max-width: 1280px; background: rgba(5,5,8,0.96); border-radius: 20px; border: 1px solid rgba(212,175,55,0.3); @@ -39,10 +40,7 @@ align-items: center; margin-bottom: 20px; gap: 16px; } .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); } .badge { border-radius: 999px; @@ -55,20 +53,17 @@ .controls { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 18px; align-items: center; - justify-content: space-between; } .control-group { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } + .spacer { flex: 1; } label { font-size: 0.8rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); } - input, select { + input { background: var(--bg-card); border-radius: 999px; border: 1px solid var(--border); padding: 8px 14px; font-size: 0.9rem; color: var(--text); outline: none; min-width: 130px; } - input:focus, select:focus { - border-color: var(--gold-soft); - box-shadow: 0 0 0 1px rgba(212,175,55,0.4); - } + input:focus { border-color: var(--gold-soft); box-shadow: 0 0 0 1px rgba(212,175,55,0.4); } button { border-radius: 999px; border: 1px solid rgba(212,175,55,0.7); @@ -77,13 +72,19 @@ font-size: 0.85rem; letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; transition: transform 0.08s, box-shadow 0.08s; + white-space: nowrap; } button:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0,0,0,0.5); } - button:active { transform: translateY(1px); } + button:active { transform: translateY(1px); box-shadow: none; } + button:disabled { opacity: 0.45; cursor: default; transform: none; } .sync-btn { 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)); } + .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)); + } .summary-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; font-size: 0.85rem; @@ -91,8 +92,7 @@ .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); + padding: 6px 14px; border: 1px solid var(--border); color: var(--muted); } .dot { width: 9px; height: 9px; border-radius: 50%; } .dot.on { background: var(--success); box-shadow: 0 0 10px rgba(46,204,113,0.7); } @@ -104,216 +104,292 @@ overflow: hidden; } table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } - 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 { - 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); } tbody tr:last-child td { border-bottom: none; } tbody tr:hover { background: rgba(212,175,55,0.04); } .name-cell { font-weight: 500; color: var(--text); } .muted-cell { color: var(--muted); font-size: 0.82rem; } .align-center { text-align: center; } + .time-first { color: var(--text); font-weight: 500; } + .time-latest { color: var(--muted); font-size: 0.85rem; } + .same-badge { color: #555; font-size: 0.82rem; font-style: italic; } .status-chip { - display: inline-flex; align-items: center; - justify-content: center; min-width: 88px; - padding: 5px 12px; border-radius: 999px; + display: inline-flex; align-items: center; justify-content: center; + min-width: 88px; padding: 5px 12px; border-radius: 999px; font-size: 0.78rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; } - .status-on { - background: rgba(46,204,113,0.1); color: #c9f7dc; - border: 1px solid rgba(46,204,113,0.65); - box-shadow: 0 0 14px rgba(46,204,113,0.2); - } - .status-off { - background: rgba(255,77,79,0.1); color: #ffd6d7; - border: 1px solid rgba(255,77,79,0.75); - box-shadow: 0 0 14px rgba(255,77,79,0.2); - } + .status-on { background: rgba(46,204,113,0.1); color: #c9f7dc; border: 1px solid rgba(46,204,113,0.65); box-shadow: 0 0 14px rgba(46,204,113,0.2); } + .status-off { background: rgba(255,77,79,0.1); color: #ffd6d7; border: 1px solid rgba(255,77,79,0.75); box-shadow: 0 0 14px rgba(255,77,79,0.2); } .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); } + + /* Modal */ + .modal-overlay { + display: none; position: fixed; inset: 0; + background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); + z-index: 100; align-items: center; justify-content: center; + } + .modal-overlay.open { display: flex; } + .modal { + background: var(--bg-card); + border: 1px solid rgba(255,100,100,0.5); + border-radius: 16px; padding: 28px 32px; + max-width: 400px; width: 90%; text-align: center; + box-shadow: 0 24px 60px rgba(0,0,0,0.8); + } + .modal h2 { color: var(--danger); font-size: 1.1rem; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.08em; } + .modal p { color: var(--muted); font-size: 0.9rem; margin-bottom: 22px; line-height: 1.6; } + .modal p strong { color: var(--text); } + .modal-actions { display: flex; gap: 12px; justify-content: center; } + .modal-cancel { + border-color: rgba(255,255,255,0.15); + background: rgba(255,255,255,0.05); + } + .modal-confirm { border-color: rgba(255,100,100,0.6); background: rgba(255,80,80,0.18); } + + /* Toast */ .toast { position: fixed; bottom: 24px; right: 24px; background: var(--bg-card); border: 1px solid rgba(212,175,55,0.5); border-radius: 12px; padding: 12px 18px; font-size: 0.85rem; color: var(--gold); opacity: 0; transform: translateY(12px); transition: opacity 0.25s, transform 0.25s; - pointer-events: none; z-index: 99; + pointer-events: none; z-index: 200; } .toast.show { opacity: 1; transform: translateY(0); } - @media (max-width: 720px) { + + @media (max-width: 800px) { header { flex-direction: column; align-items: flex-start; } .controls { flex-direction: column; align-items: stretch; } - input, select, button { width: 100%; } - th:nth-child(3), td:nth-child(3), + input, button { width: 100%; } th:nth-child(4), td:nth-child(4) { display: none; } } -
-
-
-

Building Access

-
Daily badge-in attendance — powered by UniFi Access
-
-
LIVE ATTENDANCE DASHBOARD
-
+
-
-
- - -
-
- - -
-
- - -
-
+
+
+

Building Access

+
Daily badge-in attendance — powered by UniFi Access
+
+
LIVE ATTENDANCE DASHBOARD
+
-
-
0 on time
-
0 late
-
0 total
-
+
+
+ + +
+
+ + +
+
+
+ + + +
+
-
- - - - - - - - - - - - - -
#NameBadge TimeActor IDStatus
No data yet. Badge into a door and press Refresh.
-
+
+
0 on time
+
0 late
+
0 total
+
+ +
+ + + + + + + + + + + + + + +
#NameFirst Badge InLatest Badge InActor IDStatus
No data yet. Badge into a door and press Refresh.
+
+ +
+ + + -
+
- + + document.getElementById('on-time-count').textContent = onTime + ' on time'; + document.getElementById('late-count').textContent = late + ' late'; + document.getElementById('total-count').textContent = (onTime + late) + ' total'; + } + + async function syncUsers() { + const btn = document.getElementById('sync-btn'); + btn.textContent = '⏳ Syncing…'; btn.disabled = true; + try { + await fetch('/api/sync-users'); + showToast('✔ User list synced from UniFi Access'); + await loadData(); + } catch { + showToast('⚠ Sync failed — check server logs'); + } finally { + btn.textContent = '↻ Sync Users'; btn.disabled = false; + } + } + + // ── Reset day modal ───────────────────────────────────────────────────────── + document.getElementById('reset-btn').addEventListener('click', () => { + const date = document.getElementById('date').value || isoToday(); + document.getElementById('modal-date-label').textContent = date; + document.getElementById('reset-modal').classList.add('open'); + }); + + document.getElementById('modal-cancel').addEventListener('click', () => { + document.getElementById('reset-modal').classList.remove('open'); + }); + + document.getElementById('modal-confirm').addEventListener('click', async () => { + document.getElementById('reset-modal').classList.remove('open'); + const date = document.getElementById('date').value || isoToday(); + try { + const res = await fetch('/api/reset-day?date=' + date, { method: 'DELETE' }); + const json = await res.json(); + showToast(`✔ Reset complete — ${json.deleted} record(s) deleted for ${date}`); + await loadData(); + } catch { + showToast('⚠ Reset failed — check server logs'); + } + }); + + // Close modal on overlay click + document.getElementById('reset-modal').addEventListener('click', e => { + if (e.target === document.getElementById('reset-modal')) + document.getElementById('reset-modal').classList.remove('open'); + }); + + document.getElementById('refresh-btn').addEventListener('click', loadData); + document.getElementById('sync-btn').addEventListener('click', syncUsers); + + window.addEventListener('load', () => { + const dateInput = document.getElementById('date'); + if (!dateInput.value) dateInput.value = isoToday(); + loadData(); + }); +