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:
@@ -63,7 +63,10 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
|||||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||||
const shifts = shift.listShifts({ operator, from, to });
|
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
|
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||||
|
|||||||
@@ -234,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
|||||||
await shift.open("bob"); await shift.close("bob");
|
await shift.open("bob"); await shift.close("bob");
|
||||||
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -177,6 +177,28 @@ export class ShiftService {
|
|||||||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||||||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
* 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[] {
|
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchEvents,
|
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,
|
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 isAdmin = q.data?.scope === "all";
|
||||||
const closed = q.data?.shifts ?? [];
|
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).
|
// 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;
|
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
||||||
@@ -169,7 +177,14 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("shifts.operator")}</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1151,6 +1151,8 @@ export interface ShiftSummary extends ShiftSourceSplit {
|
|||||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||||||
shifts: ShiftSummary[];
|
shifts: ShiftSummary[];
|
||||||
scope: "all" | "self";
|
scope: "all" | "self";
|
||||||
|
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||||||
|
operators?: string[];
|
||||||
}> {
|
}> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.operator) qs.set("operator", params.operator);
|
if (params.operator) qs.set("operator", params.operator);
|
||||||
|
|||||||
Reference in New Issue
Block a user