fix(shifts): operator filter — select over real operators, no more focus loss

The admin operator filter was a free-text input that broke three ways at
once: its visibility hangs off the query response (scope === "all") and
its value is part of the query key, so every keystroke started a new
query, data went undefined for the round-trip, and the input UNMOUNTED
mid-keystroke (lost focus, list blanking that read as a page reload).
Filtering also silently failed — the server matches the operator by
exact username, so partial text matched nothing.

- keepPreviousData on the shifts query: previous data (and scope) stays
  live during refetch, so filter controls never unmount and the list
  never blanks on preset/filter changes.
- The filter is now a <select> of operators that HAVE shifts: the server
  returns the distinct list (signed z-reports + the open shift's holder)
  on GET /api/shifts, admin scope only — operators still can't see other
  names. Exact match by construction.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-05 15:23:29 +02:00
parent dc2cdc0a91
commit 1de209be48
5 changed files with 53 additions and 4 deletions
+22
View File
@@ -177,6 +177,28 @@ export class ShiftService {
* The open shift (no z_report yet) is intentionally excluded — it's not a
* completed accountability period. Use `currentOpenShift()` for the live one.
*/
/**
* Every operator that HAS a shift (closed z_reports + the open one, if any),
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
* for an operator that exists (the filter is an exact username match).
*/
listOperators(): string[] {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "shift_z_report"))
.all();
const names = new Set<string>();
for (const r of rows) {
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
if (op) names.add(op);
}
const open = this.currentOpenShift();
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
if (openOp) names.add(openOp);
return [...names].sort((a, b) => a.localeCompare(b));
}
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
const rows = this.#db
.select()