diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index 9b0c428..194bc90 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -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 diff --git a/apps/server/src/shift-service.test.ts b/apps/server/src/shift-service.test.ts index ca285b1..c57da65 100644 --- a/apps/server/src/shift-service.test.ts +++ b/apps/server/src/shift-service.test.ts @@ -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"]); + }); }); diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index dde17f3..246cfb5 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -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(); + 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() diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index 81828c3..e66a4a4 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -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 && (
{t("shifts.operator")} - 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. */} +
)} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 5e36d46..0037c1e 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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);