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
+4 -1
View File
@@ -63,7 +63,10 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to });
return { shifts, scope: canSeeAll ? "all" : "self" };
// Admins also get the distinct operator list (unfiltered) for the filter
// dropdown — operators don't see other names, so it's scope-gated.
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
return { shifts, scope: "self" };
});
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
+7
View File
@@ -234,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
await shift.open("bob"); await shift.close("bob");
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
});
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
await shift.open("bob"); await shift.close("bob");
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
await shift.open("alice"); // open, no z-report yet
expect(shift.listOperators()).toEqual(["alice", "bob"]);
});
});
+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()
+18 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
closeShift,
fetchEvents,
@@ -105,9 +105,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
};
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) });
// keepPreviousData: every filter change makes a NEW query key; without it the
// data (and with it `scope`) goes undefined for the fetch round-trip, which
// unmounted the admin filter controls mid-interaction and blanked the list.
const q = useQuery({
queryKey: ["shifts", applied],
queryFn: () => fetchShifts(applied),
placeholderData: keepPreviousData,
});
const isAdmin = q.data?.scope === "all";
const closed = q.data?.shifts ?? [];
const operators = q.data?.operators ?? [];
// The current/open shift sits at the TOP of the list (when present + visible to me).
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
@@ -169,7 +177,14 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
{isAdmin && (
<div className="field">
<span className="label">{t("shifts.operator")}</span>
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
{/* A select over operators that HAVE shifts — the server filter is an
exact username match, so free text could only miss. */}
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
<option value="">{t("shifts.allOperators")}</option>
{operators.map((op) => (
<option key={op} value={op}>{op}</option>
))}
</select>
</div>
)}
</div>
+2
View File
@@ -1151,6 +1151,8 @@ export interface ShiftSummary extends ShiftSourceSplit {
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
shifts: ShiftSummary[];
scope: "all" | "self";
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
operators?: string[];
}> {
const qs = new URLSearchParams();
if (params.operator) qs.set("operator", params.operator);