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
+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>