This commit is contained in:
@@ -6,7 +6,11 @@ badge holders to real names, and displays a unified live attendance table with
|
||||
first/latest badge times, source controller, and ON TIME / LATE status.
|
||||
|
||||
**Multi-controller:** add as many UniFi Access controllers as the host can reach.
|
||||
Webhooks are auto-registered when you add a controller from the UI. (05/28/26)
|
||||
Webhooks are auto-registered when you add a controller from the UI.
|
||||
|
||||
**Tenant filtering:** hide building tenants (or any non-staff actor) from the
|
||||
attendance table with one click — events are still recorded so unfiltering
|
||||
restores their full history. (05/28/26)
|
||||
|
||||
---
|
||||
|
||||
@@ -169,8 +173,10 @@ Per-controller actions in the modal:
|
||||
| **Date picker** | Choose which day to view |
|
||||
| **Badged in by** | Set your on-time cutoff (HH:MM) |
|
||||
| **Controller** | Filter the table to one controller, or show All |
|
||||
| **Show filtered** | Include filtered tenants in the table (dimmed and tagged) |
|
||||
| **Refresh** | Reload the table |
|
||||
| **Sync Users** | Pull latest users from every enabled controller |
|
||||
| **🚫 Filtered** | Open the filtered-tenants modal to review and unhide |
|
||||
| **⚙ Controllers** | Add / manage controllers |
|
||||
| **Reset Day** | Delete all badge records for the selected date (respects the Controller filter — testing only) |
|
||||
|
||||
@@ -185,6 +191,7 @@ Per-controller actions in the modal:
|
||||
| **Latest Badge In** | Most recent entry — shows *"— same"* if only one badge event |
|
||||
| **Actor ID** | First 8 characters of the UniFi user UUID |
|
||||
| **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff |
|
||||
| **Actions** | **Hide** filters this tenant out of future views; **Unhide** restores them |
|
||||
|
||||
> The same physical person on two different controllers will appear as two rows
|
||||
> (different controllers issue different user UUIDs). They're distinguishable
|
||||
@@ -192,6 +199,25 @@ Per-controller actions in the modal:
|
||||
|
||||
---
|
||||
|
||||
## Filtering tenants
|
||||
|
||||
Use this when an actor (typically a building tenant, vendor, or contractor)
|
||||
badges into the same doors as your staff but you don't want them counted on
|
||||
the attendance table.
|
||||
|
||||
- Click **Hide** on any row to filter that actor out. They're removed from the
|
||||
table immediately and stay hidden on future days.
|
||||
- Toggle **Show filtered** in the controls bar to see them again — filtered
|
||||
rows render dimmed with a FILTERED tag and an **Unhide** action.
|
||||
- Click the **🚫 Filtered** button in the header for a bulk-management view
|
||||
across all controllers, with one-click unhide per actor.
|
||||
|
||||
The filter is per `(controller, actor)`, so the same person on two controllers
|
||||
must be hidden on each one. Badge events are still recorded while an actor is
|
||||
filtered — unhiding restores their full history with no gaps.
|
||||
|
||||
---
|
||||
|
||||
## Updating from GitHub
|
||||
|
||||
```bash
|
||||
@@ -217,7 +243,9 @@ reverse proxy with auth in front of it.
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/unifi-access/<controller_id>` | webhook body | Receives UniFi Access webhook for that controller |
|
||||
| `POST` | `/api/unifi-access` | webhook body | Legacy alias — routes to the oldest controller |
|
||||
| `GET` | `/api/first-badge-status` | `date`, `cutoff`, `controller_id?` | Returns first + latest badge per user |
|
||||
| `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/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 |
|
||||
| `GET` | `/api/controllers` | — | List configured controllers |
|
||||
| `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 |
|
||||
@@ -241,6 +269,8 @@ reverse proxy with auth in front of it.
|
||||
| Webhook URL stored in controller points to the wrong address | Browser's origin isn't reachable from the controller | Set `DASHBOARD_BASE_URL` in `.env`, remove + re-add the controller |
|
||||
| `Port 12445 connection refused` | Firewall blocking port | Add LAN IN firewall rule in UniFi Network (Step 1) |
|
||||
| Dashboard shows stale names after a user rename | Cache not refreshed | Click **Sync Users** or wait for the hourly auto-sync |
|
||||
| A tenant I hid is still showing up | Same person exists on a second controller | Hide them on each controller — the filter is per `(controller, actor)` |
|
||||
| Filtered tenant doesn't appear when I tick "Show filtered" | They've never badged in on the selected date | Open the **🚫 Filtered** modal to confirm they're filtered |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -93,11 +93,17 @@ def init_db():
|
||||
actor_id TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
filtered INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (controller_id, actor_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
if not _column_exists(db, "user_cache", "filtered"):
|
||||
db.execute(
|
||||
"ALTER TABLE user_cache ADD COLUMN filtered INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
# Seed a Default controller from env vars when the table is empty.
|
||||
existing = db.execute("SELECT COUNT(*) AS n FROM controllers").fetchone()["n"]
|
||||
default_id = None
|
||||
@@ -299,6 +305,7 @@ def first_badge_status():
|
||||
date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
|
||||
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"
|
||||
|
||||
if not re.match(r"^\d{2}:\d{2}$", cutoff):
|
||||
cutoff = "09:00"
|
||||
@@ -314,7 +321,8 @@ def first_badge_status():
|
||||
COALESCE(
|
||||
u.full_name,
|
||||
'Unknown (' || SUBSTR(b.actor_id,1,8) || '...)'
|
||||
) AS name
|
||||
) AS name,
|
||||
COALESCE(u.filtered, 0) AS filtered
|
||||
FROM badge_events b
|
||||
LEFT JOIN user_cache u
|
||||
ON u.actor_id = b.actor_id AND u.controller_id = b.controller_id
|
||||
@@ -325,6 +333,8 @@ def first_badge_status():
|
||||
if controller_filter:
|
||||
sql += " AND b.controller_id = ?"
|
||||
params.append(controller_filter)
|
||||
if not include_filtered:
|
||||
sql += " AND COALESCE(u.filtered, 0) = 0"
|
||||
sql += " GROUP BY b.actor_id, b.controller_id ORDER BY first_ts ASC"
|
||||
|
||||
with get_db() as db:
|
||||
@@ -335,12 +345,14 @@ def first_badge_status():
|
||||
first = r["first_ts"]
|
||||
latest = r["latest_ts"]
|
||||
result.append({
|
||||
"actor_id": r["actor_id"],
|
||||
"name": r["name"],
|
||||
"source": r["source"] or "—",
|
||||
"first_ts": first,
|
||||
"latest_ts": latest if latest != first else None,
|
||||
"status": "ON TIME" if first <= cutoff_end else "LATE",
|
||||
"actor_id": r["actor_id"],
|
||||
"controller_id": r["controller_id"],
|
||||
"name": r["name"],
|
||||
"source": r["source"] or "—",
|
||||
"first_ts": first,
|
||||
"latest_ts": latest if latest != first else None,
|
||||
"status": "ON TIME" if first <= cutoff_end else "LATE",
|
||||
"filtered": bool(r["filtered"]),
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
@@ -575,6 +587,97 @@ def sync_one(controller_id):
|
||||
return jsonify({"status": "ok", "synced": n})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User cache (tenant filtering)
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/api/users", methods=["GET"])
|
||||
def list_users():
|
||||
controller_id = request.args.get("controller_id", "").strip() or None
|
||||
filtered_arg = request.args.get("filtered")
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
u.controller_id,
|
||||
u.actor_id,
|
||||
u.full_name,
|
||||
u.updated_at,
|
||||
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
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
if controller_id:
|
||||
sql += " AND u.controller_id = ?"
|
||||
params.append(controller_id)
|
||||
if filtered_arg in ("0", "1"):
|
||||
sql += " AND COALESCE(u.filtered, 0) = ?"
|
||||
params.append(int(filtered_arg))
|
||||
sql += " ORDER BY c.name, u.full_name"
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(sql, params).fetchall()
|
||||
return jsonify([
|
||||
{
|
||||
"controller_id": r["controller_id"],
|
||||
"controller_name": r["controller_name"] or "—",
|
||||
"actor_id": r["actor_id"],
|
||||
"full_name": r["full_name"],
|
||||
"updated_at": r["updated_at"],
|
||||
"filtered": bool(r["filtered"]),
|
||||
}
|
||||
for r in rows
|
||||
])
|
||||
|
||||
|
||||
@app.route("/api/users/<controller_id>/<actor_id>", methods=["PATCH"])
|
||||
def update_user(controller_id, actor_id):
|
||||
body = request.get_json(silent=True) or {}
|
||||
if "filtered" not in body:
|
||||
return jsonify({"error": "filtered field required"}), 400
|
||||
filtered = 1 if body["filtered"] else 0
|
||||
|
||||
with get_db() as db:
|
||||
ctrl = db.execute(
|
||||
"SELECT id FROM controllers WHERE id = ?", (controller_id,)
|
||||
).fetchone()
|
||||
if not ctrl:
|
||||
return jsonify({"error": "unknown controller"}), 404
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
placeholder = f"User {actor_id[:8]}"
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO user_cache (controller_id, actor_id, full_name, updated_at, filtered)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(controller_id, actor_id) DO UPDATE SET
|
||||
filtered = excluded.filtered
|
||||
""",
|
||||
(controller_id, actor_id, placeholder, now_iso, filtered),
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT u.controller_id, u.actor_id, u.full_name, u.updated_at,
|
||||
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
|
||||
WHERE u.controller_id = ? AND u.actor_id = ?
|
||||
""",
|
||||
(controller_id, actor_id),
|
||||
).fetchone()
|
||||
|
||||
return jsonify({
|
||||
"controller_id": row["controller_id"],
|
||||
"controller_name": row["controller_name"] or "—",
|
||||
"actor_id": row["actor_id"],
|
||||
"full_name": row["full_name"],
|
||||
"updated_at": row["updated_at"],
|
||||
"filtered": bool(row["filtered"]),
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+161
-5
@@ -96,6 +96,32 @@
|
||||
.empty-state { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; }
|
||||
.empty-state span { color: var(--gold-soft); }
|
||||
|
||||
tr.row-filtered td { opacity: 0.45; }
|
||||
tr.row-filtered .name-cell::after {
|
||||
content: 'FILTERED'; margin-left: 8px; font-size: 0.65rem; letter-spacing: 0.1em;
|
||||
color: var(--warn); border: 1px solid rgba(243,156,18,0.5);
|
||||
padding: 1px 6px; border-radius: 999px; vertical-align: middle;
|
||||
}
|
||||
.row-action-btn {
|
||||
padding: 4px 10px; font-size: 0.7rem; letter-spacing: 0.08em;
|
||||
border-radius: 999px; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(255,255,255,0.04); color: var(--text); cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.row-action-btn:hover { border-color: rgba(212,175,55,0.6); }
|
||||
.row-action-btn.unhide { border-color: rgba(46,204,113,0.6); color: #c9f7dc; }
|
||||
|
||||
.show-filtered-toggle {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 0.78rem; color: var(--muted);
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
.show-filtered-toggle input { min-width: 0; width: auto; margin: 0; }
|
||||
.filtered-btn {
|
||||
border-color: rgba(243,156,18,0.6);
|
||||
background: radial-gradient(circle at top, rgba(243,156,18,0.18), rgba(2,2,4,0.95));
|
||||
}
|
||||
|
||||
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 100; align-items: center; justify-content: center; }
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal { background: var(--bg-card); border: 1px solid rgba(212,175,55,0.3); border-radius: 16px; padding: 24px; max-width: 720px; width: 92%; box-shadow: 0 24px 60px rgba(0,0,0,0.8); max-height: 90vh; overflow-y: auto; }
|
||||
@@ -144,7 +170,8 @@
|
||||
header { flex-direction: column; align-items: flex-start; }
|
||||
.controls { flex-direction: column; align-items: stretch; }
|
||||
input, select, button { width: 100%; }
|
||||
th:nth-child(5), td:nth-child(5) { display: none; }
|
||||
th:nth-child(5), td:nth-child(5),
|
||||
th:nth-child(6), td:nth-child(6) { display: none; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.ctrl-row { grid-template-columns: 1fr; }
|
||||
.ctrl-actions { justify-content: flex-start; }
|
||||
@@ -174,10 +201,16 @@
|
||||
<label for="controller-filter">Controller</label>
|
||||
<select id="controller-filter"><option value="">All</option></select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="show-filtered-toggle" for="show-filtered">
|
||||
<input type="checkbox" id="show-filtered"> Show filtered
|
||||
</label>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="control-group">
|
||||
<button id="refresh-btn">↻ Refresh</button>
|
||||
<button class="sync-btn" id="sync-btn">↻ Sync Users</button>
|
||||
<button class="filtered-btn" id="open-filtered-btn">🚫 Filtered</button>
|
||||
<button class="controllers-btn" id="open-controllers-btn">⚙ Controllers</button>
|
||||
<button class="reset-btn" id="reset-btn">✕ Reset Day</button>
|
||||
</div>
|
||||
@@ -200,10 +233,11 @@
|
||||
<th>Latest Badge In</th>
|
||||
<th>Actor ID</th>
|
||||
<th class="align-center">Status</th>
|
||||
<th class="align-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="7" class="empty-state">No data yet. <span>Badge into a door</span> and press Refresh.</td></tr>
|
||||
<tr><td colspan="8" class="empty-state">No data yet. <span>Badge into a door</span> and press Refresh.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
@@ -265,6 +299,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtered tenants modal -->
|
||||
<div class="modal-overlay" id="filtered-modal">
|
||||
<div class="modal">
|
||||
<h2>Filtered Tenants</h2>
|
||||
<p>These actors are hidden from the attendance table. Their badge events are
|
||||
still recorded — unhide to bring them back.</p>
|
||||
|
||||
<div class="ctrl-list" id="filtered-list">
|
||||
<div class="empty-state">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" id="filtered-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
@@ -297,9 +348,11 @@
|
||||
async function loadData() {
|
||||
const date = document.getElementById('date').value || isoToday();
|
||||
const cutoff = document.getElementById('cutoff').value || '09:00';
|
||||
const controllerId = document.getElementById('controller-filter').value;
|
||||
const controllerId = document.getElementById('controller-filter').value;
|
||||
const showFiltered = document.getElementById('show-filtered').checked;
|
||||
const params = new URLSearchParams({ date, cutoff });
|
||||
if (controllerId) params.set('controller_id', controllerId);
|
||||
if (controllerId) params.set('controller_id', controllerId);
|
||||
if (showFiltered) params.set('include_filtered', '1');
|
||||
|
||||
let data;
|
||||
try {
|
||||
@@ -314,7 +367,7 @@
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!data.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No badge-in records for this day.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">No badge-in records for this day.</td></tr>';
|
||||
['on-time-count','late-count','total-count'].forEach(id => {
|
||||
document.getElementById(id).textContent = '0';
|
||||
});
|
||||
@@ -325,6 +378,7 @@
|
||||
|
||||
data.forEach((row, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
if (row.filtered) tr.classList.add('row-filtered');
|
||||
|
||||
const numTd = document.createElement('td');
|
||||
numTd.className = 'muted-cell';
|
||||
@@ -372,6 +426,18 @@
|
||||
statusTd.appendChild(statusChip);
|
||||
tr.appendChild(statusTd);
|
||||
|
||||
const actionTd = document.createElement('td');
|
||||
actionTd.className = 'align-center';
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'row-action-btn' + (row.filtered ? ' unhide' : '');
|
||||
actionBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
|
||||
actionBtn.dataset.controllerId = row.controller_id || '';
|
||||
actionBtn.dataset.actorId = row.actor_id || '';
|
||||
actionBtn.dataset.filtered = row.filtered ? '1' : '0';
|
||||
actionBtn.dataset.name = row.name || '';
|
||||
actionTd.appendChild(actionBtn);
|
||||
tr.appendChild(actionTd);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
isOnTime ? onTime++ : late++;
|
||||
});
|
||||
@@ -381,6 +447,36 @@
|
||||
document.getElementById('total-count').textContent = onTime + late;
|
||||
}
|
||||
|
||||
async function setActorFiltered(controllerId, actorId, filtered) {
|
||||
if (!controllerId || !actorId) {
|
||||
showToast('Missing controller or actor id', true);
|
||||
return false;
|
||||
}
|
||||
const r = await fetch(`/api/users/${controllerId}/${actorId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filtered }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
showToast(`Failed: ${j.error || r.status}`, true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
document.getElementById('table-body').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('.row-action-btn');
|
||||
if (!btn) return;
|
||||
const wasFiltered = btn.dataset.filtered === '1';
|
||||
btn.disabled = true;
|
||||
const ok = await setActorFiltered(btn.dataset.controllerId, btn.dataset.actorId, !wasFiltered);
|
||||
btn.disabled = false;
|
||||
if (!ok) return;
|
||||
showToast(wasFiltered ? `Unhid ${btn.dataset.name}` : `Hid ${btn.dataset.name}`);
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function syncUsers() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
const orig = btn.innerHTML;
|
||||
@@ -591,10 +687,70 @@
|
||||
}
|
||||
});
|
||||
|
||||
// -------- Filtered tenants modal --------
|
||||
async function renderFiltered() {
|
||||
const list = document.getElementById('filtered-list');
|
||||
list.innerHTML = '<div class="empty-state">Loading…</div>';
|
||||
let items = [];
|
||||
try {
|
||||
const res = await fetch('/api/users?filtered=1');
|
||||
items = await res.json();
|
||||
} catch {
|
||||
list.innerHTML = '<div class="empty-state">Failed to load filtered tenants.</div>';
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<div class="empty-state">No filtered tenants. Use the <span>Hide</span> button on any row to filter someone out.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
items.forEach(u => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ctrl-row';
|
||||
row.innerHTML = `
|
||||
<div class="ctrl-meta">
|
||||
<div class="ctrl-name">${escapeHtml(u.full_name)}</div>
|
||||
<div class="ctrl-sub">${escapeHtml(u.controller_name)} · ${escapeHtml((u.actor_id || '').slice(0,8))}…</div>
|
||||
</div>
|
||||
<div class="ctrl-actions">
|
||||
<button class="small-btn sync-btn" data-act="unhide"
|
||||
data-controller-id="${escapeHtml(u.controller_id)}"
|
||||
data-actor-id="${escapeHtml(u.actor_id)}"
|
||||
data-name="${escapeHtml(u.full_name)}">Unhide</button>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('open-filtered-btn').addEventListener('click', async () => {
|
||||
document.getElementById('filtered-modal').classList.add('open');
|
||||
await renderFiltered();
|
||||
});
|
||||
document.getElementById('filtered-close').addEventListener('click', () => {
|
||||
document.getElementById('filtered-modal').classList.remove('open');
|
||||
});
|
||||
document.getElementById('filtered-modal').addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('filtered-modal'))
|
||||
document.getElementById('filtered-modal').classList.remove('open');
|
||||
});
|
||||
document.getElementById('filtered-list').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('button[data-act="unhide"]');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
const ok = await setActorFiltered(btn.dataset.controllerId, btn.dataset.actorId, false);
|
||||
btn.disabled = false;
|
||||
if (!ok) return;
|
||||
showToast(`Unhid ${btn.dataset.name}`);
|
||||
await renderFiltered();
|
||||
await loadData();
|
||||
});
|
||||
|
||||
// -------- Wire up --------
|
||||
document.getElementById('refresh-btn').addEventListener('click', loadData);
|
||||
document.getElementById('sync-btn').addEventListener('click', syncUsers);
|
||||
document.getElementById('controller-filter').addEventListener('change', loadData);
|
||||
document.getElementById('show-filtered').addEventListener('change', loadData);
|
||||
|
||||
window.addEventListener('load', async () => {
|
||||
const dateInput = document.getElementById('date');
|
||||
|
||||
Reference in New Issue
Block a user