From df00e56d6efe5ba829873c8cc3d1b823be54dae3 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 13:12:10 -0300 Subject: [PATCH 01/20] fix(vendors): gate trades to canonical list, preserve legacy values (SH-249) --- .../vendor-trade-specialties-field.tsx | 103 +++++++---- .../vendor-trade-specialties-field.test.tsx | 168 ++++++++++++++++++ 2 files changed, 239 insertions(+), 32 deletions(-) create mode 100644 src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx index ff63ef4c..90329e41 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -1,19 +1,11 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Controller, type Control } from "react-hook-form"; import AddIcon from "@mui/icons-material/Add"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import CloseIcon from "@mui/icons-material/Close"; -import { - Autocomplete, - Box, - Button, - Chip, - FormLabel, - Stack, - TextField, - Typography, -} from "@mui/material"; +import { Autocomplete, Box, Button, Chip, FormLabel, Stack, TextField } from "@mui/material"; +import { Text } from "@/components/ui/text"; import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; function splitTrades(value: string | undefined): string[] { @@ -23,6 +15,25 @@ function splitTrades(value: string | undefined): string[] { .filter(Boolean); } +function dedupeTrades(options: string[], current: string[]): string[] { + const seen = new Set(); + const merged: string[] = []; + for (const trade of [...options, ...current]) { + const key = trade.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + merged.push(trade); + } + return merged; +} + +function matchTradeOption(input: string, options: string[]): string | null { + const exact = options.find((option) => option === input); + if (exact) return exact; + const normalized = input.toLowerCase(); + return options.find((option) => option.toLowerCase() === normalized) ?? null; +} + interface VendorTradeSpecialtiesFieldProps { control: Control; index: number; @@ -35,22 +46,44 @@ export function VendorTradeSpecialtiesField({ tradeOptions, }: VendorTradeSpecialtiesFieldProps) { const [tradeInput, setTradeInput] = useState(""); + const [feedbackMessage, setFeedbackMessage] = useState(null); + const knownTradesRef = useRef>(new Set()); return ( Trade Specialties - + First trade is primary. Reorder with the arrows. - + { const trades = splitTrades(field.value); + for (const trade of trades) knownTradesRef.current.add(trade); + const selectableTrades = dedupeTrades(tradeOptions, [...knownTradesRef.current]); const commit = (next: string[]) => field.onChange(next.join(", ")); const add = (value: string) => { const normalized = value.trim(); - if (normalized && !trades.includes(normalized)) commit([...trades, normalized]); + setFeedbackMessage(null); + if (!normalized) { + setTradeInput(""); + return; + } + const canonical = matchTradeOption(normalized, selectableTrades); + if (tradeOptions.length > 0 && !canonical) { + setFeedbackMessage( + `"${normalized}" is not in the trades list. Pick a trade from the list.`, + ); + return; + } + const trade = canonical ?? normalized; + if (trades.some((existing) => existing.toLowerCase() === trade.toLowerCase())) { + setFeedbackMessage(`${trade} is already on this technician.`); + setTradeInput(""); + return; + } + commit([...trades, trade]); setTradeInput(""); }; const move = (tradeIndex: number, direction: -1 | 1) => { @@ -64,31 +97,31 @@ export function VendorTradeSpecialtiesField({ return ( - {trades.length === 0 ? ( - - No trades selected. - - ) : ( - trades.map((trade, tradeIndex) => ( - - commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex)) - } - deleteIcon={} - /> - )) - )} + + No trades selected. + + {trades.map((trade, tradeIndex) => ( + + commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex)) + } + deleteIcon={} + /> + ))} { - if (reason === "input") setTradeInput(value); + if (reason === "input") { + setTradeInput(value); + setFeedbackMessage(null); + } }} onChange={(_event, value) => { if (typeof value === "string") add(value); @@ -117,6 +150,12 @@ export function VendorTradeSpecialtiesField({ + + Trade list is unavailable right now — you can still type a trade manually. + + + {feedbackMessage} + {trades.length > 1 && ( {trades.map((trade, tradeIndex) => ( diff --git a/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx new file mode 100644 index 00000000..bff2a587 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx @@ -0,0 +1,168 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { useEffect } from "react"; +import { useForm, useWatch, type Control } from "react-hook-form"; +import { describe, expect, it } from "vitest"; +import { VendorTradeSpecialtiesField } from "@/app/(protected)/vendors/_components/vendor-trade-specialties-field"; +import { + emptyRosterTechnician, + emptyVendorCompanyRosterForm, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { renderWithProviders } from "@/test/test-utils"; + +function TradeSpecialtiesSpy({ + control, + valuesRef, +}: { + control: Control; + valuesRef: { current: string }; +}) { + const value = useWatch({ control, name: "technicians.0.tradeSpecialties" }) ?? ""; + useEffect(() => { + valuesRef.current = value; + }, [value, valuesRef]); + return null; +} + +function TradeSpecialtiesHarness({ + tradeOptions, + initialTradeSpecialties = "", + valuesRef, +}: { + tradeOptions: string[]; + initialTradeSpecialties?: string; + valuesRef: { current: string }; +}) { + const { control } = useForm({ + defaultValues: { + ...emptyVendorCompanyRosterForm, + technicians: [{ ...emptyRosterTechnician, tradeSpecialties: initialTradeSpecialties }], + }, + }); + return ( + <> + + + + ); +} + +function getAddTradeInput(): HTMLElement { + return screen.getByRole("combobox", { name: "Add Trade" }); +} + +function typeAndCommitTrade(input: HTMLElement, value: string, commit: "enter" | "button") { + fireEvent.change(input, { target: { value } }); + if (commit === "enter") { + fireEvent.keyDown(input, { key: "Enter" }); + } else { + fireEvent.click(screen.getByRole("button", { name: "Add trade" })); + } +} + +describe("VendorTradeSpecialtiesField", () => { + it("offers facet trades as selectable options when the facets query is populated", async () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + const input = getAddTradeInput(); + fireEvent.change(input, { target: { value: "H" } }); + + fireEvent.click(await screen.findByRole("option", { name: "HVAC" })); + + expect(await screen.findByText("HVAC (primary)")).toBeInTheDocument(); + expect(valuesRef.current).toBe("HVAC"); + }); + + it("has no selectable options when the trades facet is empty, warns, and still accepts typed trades", () => { + const valuesRef = { current: "" }; + renderWithProviders(); + + expect(screen.getByText(/trade list is unavailable right now/i)).toBeInTheDocument(); + + const input = getAddTradeInput(); + fireEvent.change(input, { target: { value: "P" } }); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + + typeAndCommitTrade(input, "Plumbing", "enter"); + + expect(screen.getByText("Plumbing (primary)")).toBeInTheDocument(); + expect(valuesRef.current).toBe("Plumbing"); + }); + + it("rejects trades outside the canonical list with visible feedback and keeps the draft", () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + const input = getAddTradeInput(); + typeAndCommitTrade(input, "Sprinkler Fitting", "button"); + + expect(screen.getByText(/is not in the trades list/i)).toBeInTheDocument(); + expect(screen.queryByText("Sprinkler Fitting (primary)")).toBeNull(); + expect(valuesRef.current).toBe(""); + expect(input).toHaveValue("Sprinkler Fitting"); + }); + + it("commits the canonical casing when a typed trade matches case-insensitively", () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + const input = getAddTradeInput(); + typeAndCommitTrade(input, "hvac", "enter"); + + expect(screen.getByText("HVAC (primary)")).toBeInTheDocument(); + expect(valuesRef.current).toBe("HVAC"); + }); + + it("keeps legacy free-text trades visible, removable, and re-selectable", async () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + expect(screen.getByText("Backflow Testing (primary)")).toBeInTheDocument(); + expect(screen.getByText("HVAC")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("remove-trade-Backflow Testing")); + expect(screen.queryByText(/Backflow Testing/)).toBeNull(); + expect(valuesRef.current).toBe("HVAC"); + + const input = getAddTradeInput(); + fireEvent.change(input, { target: { value: "Back" } }); + fireEvent.click(await screen.findByRole("option", { name: "Backflow Testing" })); + + expect(await screen.findByText("Backflow Testing")).toBeInTheDocument(); + expect(valuesRef.current).toBe("HVAC, Backflow Testing"); + }); + + it("flags duplicate trades instead of silently doing nothing", () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + const input = getAddTradeInput(); + typeAndCommitTrade(input, "plumbing", "enter"); + + expect(screen.getByText(/is already on this technician/i)).toBeInTheDocument(); + expect(valuesRef.current).toBe("Plumbing"); + }); +}); From 63e37dd0bb2f80404ca02b0feeacd0c1d3a70238 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 14:27:42 -0300 Subject: [PATCH 02/20] fix(vendors): keep trades live region out of Stack spacing (SH-249) The always-mounted aria-live feedback region inherited MUI Stack spacing, adding a permanent gap under the trades input and shifting the approved Vendor edit drawer layout. vendor-visual-regression caught it. Move the region outside the spaced Stack and collapse it while silent, so the live region stays in the DOM for announcements without claiming layout. Restores the caption to MUI Typography; that swap was cosmetic and out of scope for SH-249. --- .../vendor-trade-specialties-field.tsx | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx index 90329e41..490f554d 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -4,10 +4,53 @@ import AddIcon from "@mui/icons-material/Add"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import CloseIcon from "@mui/icons-material/Close"; -import { Autocomplete, Box, Button, Chip, FormLabel, Stack, TextField } from "@mui/material"; +import { + Autocomplete, + Box, + Button, + Chip, + FormLabel, + Stack, + TextField, + Typography, +} from "@mui/material"; import { Text } from "@/components/ui/text"; import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +/** + * `Text variant="feedback"` stays mounted when silent so the aria-live region can announce later. + * An empty paragraph still claims a line box, which would add a permanent gap under the trades + * input and shift the approved drawer layout. Collapse the box while keeping the node in the DOM + * (never `display: none`, which would stop announcements). + */ +function collapsedWhenSilent(visible: boolean) { + return visible ? undefined : { height: 0, margin: 0, overflow: "hidden" as const }; +} + +function TradeFeedbackRegions({ + listUnavailable, + message, +}: { + listUnavailable: boolean; + message: string | null; +}) { + return ( + <> + + Trade list is unavailable right now — you can still type a trade manually. + + + {message} + + + ); +} + function splitTrades(value: string | undefined): string[] { return (value ?? "") .split(",") @@ -52,9 +95,9 @@ export function VendorTradeSpecialtiesField({ return ( Trade Specialties - + First trade is primary. Reorder with the arrows. - + - - Trade list is unavailable right now — you can still type a trade manually. - - - {feedbackMessage} - {trades.length > 1 && ( {trades.map((trade, tradeIndex) => ( @@ -186,6 +223,9 @@ export function VendorTradeSpecialtiesField({ ); }} /> + {/* Outside the spaced Stack: an always-mounted live region would otherwise inherit Stack + spacing and add a permanent gap under the trades input, shifting the approved layout. */} + ); } From 64caab18326eb34e81d793b7e837f1f1ec01f19e Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 17:25:44 -0300 Subject: [PATCH 03/20] fix(vendors): do not fail open while the trades facet is loading (SH-249) An empty tradeOptions array meant two different things: "facets query still in flight" and "vocabulary genuinely unavailable". Both enabled fail-open free text and showed the outage warning, so a normal cold fetch looked like an outage. Anything committed in that window was added to knownTradesRef and stayed selectable after the canonical list arrived, permanently undermining the gate. Expose the facets query isLoading as tradesLoading, thread it to the field as tradeOptionsLoading, and keep the canonical gate active while loading. The outage warning now shows only once the query has settled empty. --- .../_components/use-vendor-roster-form.ts | 4 ++- .../_components/vendor-create-modal.tsx | 1 + .../_components/vendor-detail-drawer.tsx | 1 + .../_components/vendor-roster-form-fields.tsx | 22 ++++++++++++++-- .../_components/vendor-roster-page.tsx | 1 + .../vendor-trade-specialties-field.tsx | 18 ++++++++++--- .../vendor-trade-specialties-field.test.tsx | 26 ++++++++++++++++++- 7 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 6bf89b1f..c285f592 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -64,6 +64,7 @@ export interface VendorRosterForm { roster: VendorCompanyRoster | undefined; companies: VendorFacetCompany[]; trades: string[]; + tradesLoading: boolean; selectedCompanyId: string | number | null; selectCompany: (company: VendorFacetCompany | null) => Promise; clearSelectedCompany: (nextName?: string) => void; @@ -81,7 +82,7 @@ export function useVendorRosterForm({ mode === "update" ? vendorId : undefined, mode === "update" ? companyId : undefined, ); - const { data: facets } = useVendorFacets(); + const { data: facets, isLoading: facetsLoading } = useVendorFacets(); const save = useSaveVendorCompanyRoster(); const [conflict, setConflict] = useState(null); @@ -168,6 +169,7 @@ export function useVendorRosterForm({ roster: routeRoster, companies, trades, + tradesLoading: facetsLoading, selectedCompanyId: selection.selectedCompanyId, selectCompany: selection.selectCompany, clearSelectedCompany: selection.clearSelectedCompany, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 31804a3c..002a81bf 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -68,6 +68,7 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { errors={form.errors} companies={form.companies} tradeOptions={form.trades} + tradeOptionsLoading={form.tradesLoading} selectedCompanyId={form.selectedCompanyId} onSelectCompany={form.selectCompany} onClearSelectedCompany={form.clearSelectedCompany} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 74d6ef02..7a365a48 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -288,6 +288,7 @@ function DrawerEditor({ control={form.control} errors={form.errors} tradeOptions={form.trades} + tradeOptionsLoading={form.tradesLoading} showTechnicianStatus={false} /> {selectedIndex >= 0 && ( diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index d6d5cbf7..93a10fd9 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -25,6 +25,7 @@ interface VendorRosterFormFieldsProps { errors: FieldErrors; companies?: VendorFacetCompany[]; tradeOptions?: string[]; + tradeOptionsLoading?: boolean; selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; @@ -236,6 +237,7 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; + tradeOptionsLoading?: boolean; showStatus: boolean; } @@ -246,6 +248,7 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, + tradeOptionsLoading, showStatus, }: TechnicianRowProps) { return ( @@ -315,7 +318,12 @@ function TechnicianRow({ )} /> - + {showStatus && ( ; errors: FieldErrors; tradeOptions: string[]; + tradeOptionsLoading?: boolean; showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -389,6 +399,7 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} + tradeOptionsLoading={tradeOptionsLoading} showStatus={showStatus} /> )) @@ -399,7 +410,13 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; + const { + control, + errors, + tradeOptions = [], + tradeOptionsLoading = false, + showTechnicianStatus = true, + } = props; return ( @@ -408,6 +425,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { control={control} errors={errors} tradeOptions={tradeOptions} + tradeOptionsLoading={tradeOptionsLoading} showStatus={showTechnicianStatus} /> diff --git a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx index f0316957..45f1bb19 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx @@ -124,6 +124,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa control={form.control} errors={form.errors} tradeOptions={form.trades} + tradeOptionsLoading={form.tradesLoading} {...companySelectionProps} /> diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx index 490f554d..ef7b0043 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -81,16 +81,26 @@ interface VendorTradeSpecialtiesFieldProps { control: Control; index: number; tradeOptions: string[]; + tradeOptionsLoading?: boolean; } export function VendorTradeSpecialtiesField({ control, index, tradeOptions, + tradeOptionsLoading = false, }: VendorTradeSpecialtiesFieldProps) { const [tradeInput, setTradeInput] = useState(""); const [feedbackMessage, setFeedbackMessage] = useState(null); const knownTradesRef = useRef>(new Set()); + // SH-249: an empty tradeOptions array means two different things. While the + // facets query is in flight it means "not loaded yet"; only once it settles + // does it mean "vocabulary genuinely unavailable". Failing open during the + // cold-fetch window let free text through the canonical gate permanently, + // because anything committed there is added to knownTradesRef and stays + // selectable after the real list arrives. + const tradeListUnavailable = !tradeOptionsLoading && tradeOptions.length === 0; + const gateActive = tradeOptions.length > 0 || tradeOptionsLoading; return ( @@ -114,9 +124,11 @@ export function VendorTradeSpecialtiesField({ return; } const canonical = matchTradeOption(normalized, selectableTrades); - if (tradeOptions.length > 0 && !canonical) { + if (gateActive && !canonical) { setFeedbackMessage( - `"${normalized}" is not in the trades list. Pick a trade from the list.`, + tradeOptionsLoading + ? "Trades are still loading. Wait for the list, then pick a trade." + : `"${normalized}" is not in the trades list. Pick a trade from the list.`, ); return; } @@ -225,7 +237,7 @@ export function VendorTradeSpecialtiesField({ /> {/* Outside the spaced Stack: an always-mounted live region would otherwise inherit Stack spacing and add a permanent gap under the trades input, shifting the approved layout. */} - + ); } diff --git a/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx index bff2a587..03990707 100644 --- a/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx @@ -26,10 +26,12 @@ function TradeSpecialtiesSpy({ function TradeSpecialtiesHarness({ tradeOptions, + tradeOptionsLoading = false, initialTradeSpecialties = "", valuesRef, }: { tradeOptions: string[]; + tradeOptionsLoading?: boolean; initialTradeSpecialties?: string; valuesRef: { current: string }; }) { @@ -41,7 +43,12 @@ function TradeSpecialtiesHarness({ }); return ( <> - + ); @@ -79,6 +86,23 @@ describe("VendorTradeSpecialtiesField", () => { expect(valuesRef.current).toBe("HVAC"); }); + it("does not fail open while the trades facet is still loading", () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + // A cold fetch must not look like an outage. + expect(screen.queryByText(/trade list is unavailable right now/i)).toBeNull(); + + const input = getAddTradeInput(); + typeAndCommitTrade(input, "Plumbing", "enter"); + + expect(screen.getByText(/trades are still loading/i)).toBeInTheDocument(); + expect(screen.queryByText("Plumbing (primary)")).toBeNull(); + expect(valuesRef.current).toBe(""); + }); + it("has no selectable options when the trades facet is empty, warns, and still accepts typed trades", () => { const valuesRef = { current: "" }; renderWithProviders(); From cccb0e4ca7692e976b458829eafa9d404f6deac7 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 18:09:23 -0300 Subject: [PATCH 04/20] refactor(vendors): extract trade rejection message to satisfy length gate (SH-249) The loading-aware gate pushed VendorTradeSpecialtiesField to 151 lines, one over the changed-file maintainability cap. Move the rejection-message resolution and the contract explanation to module scope; no behavior change. --- .../vendor-trade-specialties-field.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx index ef7b0043..11a7ea4b 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -84,6 +84,22 @@ interface VendorTradeSpecialtiesFieldProps { tradeOptionsLoading?: boolean; } +/** + * SH-249: an empty tradeOptions array means two different things. While the facets + * query is in flight it means "not loaded yet"; only once it settles does it mean + * "vocabulary genuinely unavailable". Failing open during the cold-fetch window let + * free text through the canonical gate permanently, because anything committed there + * is added to knownTradesRef and stays selectable after the real list arrives. + * + * SH-249: why a typed trade was refused. Extracted to module scope so the field + * component stays inside the maintainability gate's function-length cap. + */ +function resolveRejectionMessage(value: string, loading: boolean): string { + return loading + ? "Trades are still loading. Wait for the list, then pick a trade." + : `"${value}" is not in the trades list. Pick a trade from the list.`; +} + export function VendorTradeSpecialtiesField({ control, index, @@ -93,12 +109,6 @@ export function VendorTradeSpecialtiesField({ const [tradeInput, setTradeInput] = useState(""); const [feedbackMessage, setFeedbackMessage] = useState(null); const knownTradesRef = useRef>(new Set()); - // SH-249: an empty tradeOptions array means two different things. While the - // facets query is in flight it means "not loaded yet"; only once it settles - // does it mean "vocabulary genuinely unavailable". Failing open during the - // cold-fetch window let free text through the canonical gate permanently, - // because anything committed there is added to knownTradesRef and stays - // selectable after the real list arrives. const tradeListUnavailable = !tradeOptionsLoading && tradeOptions.length === 0; const gateActive = tradeOptions.length > 0 || tradeOptionsLoading; @@ -125,11 +135,7 @@ export function VendorTradeSpecialtiesField({ } const canonical = matchTradeOption(normalized, selectableTrades); if (gateActive && !canonical) { - setFeedbackMessage( - tradeOptionsLoading - ? "Trades are still loading. Wait for the list, then pick a trade." - : `"${normalized}" is not in the trades list. Pick a trade from the list.`, - ); + setFeedbackMessage(resolveRejectionMessage(normalized, tradeOptionsLoading)); return; } const trade = canonical ?? normalized; From 1554cf30d0a1a074e921689f164a291ec5400e87 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 11:49:19 -0300 Subject: [PATCH 05/20] fix(work-orders): require vendor company not technician to complete (SH-183) --- .../list/table/cells/status-cell.tsx | 1 + .../work-orders/utils/wo-closability.ts | 19 +++++++---- .../status-cell-closability.test.tsx | 1 + .../status-cell-past-due-copy.test.tsx | 1 + .../status-cell-pending-uplift.test.tsx | 20 ++++++++++- .../work-orders/utils/wo-closability.test.ts | 33 +++++++++++++++++++ 6 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 494c8fef..0723150d 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -26,6 +26,7 @@ function rowClosabilityGaps(wo: WorkOrderTableRow) { dispatcherId: wo.dispatcherId, dispatcherName: wo.dispatcherName, company: wo.company, + vendorId: wo.vendorId ?? "", tech: wo.tech, completedDate: wo.completedDate ?? "", woNumber: wo.woNumber, diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 1290ecd3..823a241c 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -11,6 +11,8 @@ export type WoClosabilityInput = { dispatcherName: string; /** Company / vendor assignment — required for Completed (SH-183). */ company: string; + /** Board vendor id; assignment is valid with vendorId even when company label is empty. */ + vendorId: string; /** Technician is optional for closability; modeled separately from company. */ tech: string; /** Work completed date (API CompletedDate). Empty until set. */ @@ -57,8 +59,8 @@ function hasDispatcher(row: Pick): boolean { + return Boolean((row.company ?? "").trim() || (row.vendorId ?? "").trim()); } function hasService(pm: string): boolean { @@ -80,7 +82,7 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { if (!hasDispatcher(row)) { gaps.push("Dispatcher"); } - if (!hasCompany(row.company)) { + if (!hasAssignedCompany(row)) { gaps.push("Company"); } if (!row.completedDate.trim()) { @@ -143,6 +145,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl dispatcherId: draft.assigneeId, dispatcherName: draft.assigneeName, company: draft.vendorName, + vendorId: draft.vendorId ?? "", tech: "", completedDate: "", woNumber: draft.woNumber, @@ -159,13 +162,15 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl * as WorkOrderType. `problem` is the same service field the board maps into `pm`. */ export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput { - const vendorName = wo.dispatches.find((dispatch) => - Boolean(dispatch.vendorName.trim()), - )?.vendorName; + const assigned = wo.dispatches.find((dispatch) => { + const vendorId = String(dispatch.vendorId ?? "").trim(); + return Boolean(dispatch.vendorName.trim() || vendorId); + }); return { dispatcherId: "", dispatcherName: wo.assignedTo, - company: vendorName ?? "", + company: assigned?.vendorName ?? "", + vendorId: assigned ? String(assigned.vendorId ?? "").trim() : "", tech: "", completedDate: wo.completedDate, woNumber: wo.workOrderNumber || wo.internalWONumber, diff --git a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx index 1ca88e1b..828d260f 100644 --- a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx @@ -9,6 +9,7 @@ const incompleteRow = { dispatcherId: "", dispatcherName: "", company: "", + vendorId: "", tech: "", completedDate: "", woNumber: "", diff --git a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx index 961e830c..df022f15 100644 --- a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx @@ -9,6 +9,7 @@ const pastDueRow = { dispatcherId: "", dispatcherName: "", company: "", + vendorId: "", tech: "", completedDate: "", woNumber: "", diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx index a3755204..577c1469 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -125,7 +125,7 @@ describe("StatusCell pending uplift closability", () => { renderWithProviders( , @@ -141,6 +141,24 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).not.toHaveBeenCalled(); }); + it("allows Completed when vendorId is set even if company label is empty (SH-183)", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + }); + it("allows Completed when technician is empty if company is set (SH-183)", () => { const onChangeStatus = vi.fn(); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index c900ccec..7e60565f 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -21,6 +21,7 @@ function makeInput(overrides: Partial = {}): WoClosabilityIn dispatcherId: "u1", dispatcherName: "Alex", company: "Acme HVAC", + vendorId: "", tech: "Sam", completedDate: "2026-07-01", woNumber: "1001", @@ -85,6 +86,16 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); }); + it("allows Completed when vendorId is set and company/technician are empty (SH-183)", () => { + expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]); + }); + + it("blocks Completed when both company and vendorId are empty even with a technician", () => { + expect(getClosabilityGaps(makeInput({ company: "", vendorId: " ", tech: "Sam" }))).toEqual([ + "Company", + ]); + }); + it("lists field gaps before uplift and document gaps", () => { expect( getClosabilityGaps( @@ -188,6 +199,7 @@ describe("wizardDraftToClosabilityInput", () => { dispatcherId: "u1", dispatcherName: "Alice", company: "ACME", + vendorId: "", tech: "", completedDate: "", woNumber: "20260623001", @@ -221,6 +233,7 @@ describe("detailToClosabilityInput", () => { const input = detailToClosabilityInput(detail); expect(input.dispatcherName).toBe("Alice"); expect(input.company).toBe("ACME Tech"); + expect(input.vendorId).toBe(""); expect(input.woNumber).toBe("20260623001"); expect(input.pm).toBe("HVAC PM"); expect(getClosabilityGaps(input)).toEqual([]); @@ -277,6 +290,26 @@ describe("detailToClosabilityInput", () => { expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]); }); + + it("treats vendorId without vendorName as assigned company (SH-183)", () => { + const detail = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "HVAC PM", + source: "procurement-integration", + type: "", + docStatus: "", + mediaCount: 0, + dispatches: [{ vendorName: "", vendorId: 45 }], + } as WorkOrderDetail; + + const input = detailToClosabilityInput(detail); + expect(input.company).toBe(""); + expect(input.vendorId).toBe("45"); + expect(getClosabilityGaps(input)).toEqual([]); + }); }); describe("readDetailWoType", () => { From a43de20ed4ee7dabba34d227926c155c8a17f274 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 12:02:17 -0300 Subject: [PATCH 06/20] fix(work-orders): ignore cancelled dispatches in detail closability (SH-183) --- .../work-orders/utils/wo-closability.ts | 19 ++++++-- .../work-orders/utils/wo-closability.test.ts | 47 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 823a241c..ab9f9494 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -1,3 +1,4 @@ +import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispatch"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; import type { CompletionDocStatus, @@ -156,16 +157,26 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl }; } +/** Current vendor assignment — cancelled dispatches do not count as company (SH-183). */ +function currentAssignedDispatch( + dispatches: WorkOrderDispatchSummary[], +): WorkOrderDispatchSummary | undefined { + return dispatches.find((dispatch) => { + if (dispatch.status === "Cancelled") { + return false; + } + const vendorId = String(dispatch.vendorId ?? "").trim(); + return Boolean((dispatch.vendorName ?? "").trim() || vendorId); + }); +} + /** * Map standalone detail DTO onto closability. Uses authoritative `type` / `docStatus` / * `mediaCount` (enriched from board detail on the view route). Never treats `source` * as WorkOrderType. `problem` is the same service field the board maps into `pm`. */ export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput { - const assigned = wo.dispatches.find((dispatch) => { - const vendorId = String(dispatch.vendorId ?? "").trim(); - return Boolean(dispatch.vendorName.trim() || vendorId); - }); + const assigned = currentAssignedDispatch(wo.dispatches); return { dispatcherId: "", dispatcherName: wo.assignedTo, diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 7e60565f..9aa2c15e 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -96,6 +96,22 @@ describe("getClosabilityGaps", () => { ]); }); + it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)( + "blocks Completed without a company for type %s (SH-183)", + (type) => { + const gaps = getClosabilityGaps( + makeInput({ + company: "", + vendorId: "", + type, + docStatus: "Yes", + mediaCount: 1, + }), + ); + expect(gaps).toContain("Company"); + }, + ); + it("lists field gaps before uplift and document gaps", () => { expect( getClosabilityGaps( @@ -310,6 +326,37 @@ describe("detailToClosabilityInput", () => { expect(input.vendorId).toBe("45"); expect(getClosabilityGaps(input)).toEqual([]); }); + + it("ignores cancelled dispatches when resolving assigned company (SH-183)", () => { + const cancelledOnly = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "HVAC PM", + source: "procurement-integration", + type: "", + docStatus: "", + mediaCount: 0, + dispatches: [{ vendorName: "", vendorId: 45, status: "Cancelled" }], + } as WorkOrderDetail; + + expect(detailToClosabilityInput(cancelledOnly).vendorId).toBe(""); + expect(getClosabilityGaps(detailToClosabilityInput(cancelledOnly))).toContain("Company"); + + const supersededThenCurrent = { + ...cancelledOnly, + dispatches: [ + { vendorName: "Old Co", vendorId: 1, status: "Cancelled" }, + { vendorName: "ACME Tech", vendorId: 45, status: "Sent" }, + ], + } as WorkOrderDetail; + + const input = detailToClosabilityInput(supersededThenCurrent); + expect(input.company).toBe("ACME Tech"); + expect(input.vendorId).toBe("45"); + expect(getClosabilityGaps(input)).toEqual([]); + }); }); describe("readDetailWoType", () => { From 22b03b8ac08f39fdace3a151d936b26688b6d5f8 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 19 Aug 2026 13:36:37 -0300 Subject: [PATCH 07/20] fix(vendors): deactivation dialog follows the approved design (SH-254) The dialog's wording, its actions and its behaviour all diverged from the design prototype. It refused deactivation outright when the vendor had open work orders, which SH-44 and SH-82 left as an open question rather than a decided requirement. Deactivation with open work orders now proceeds on explicit confirmation: the design's title and body copy, the affected work orders listed as links, and "Deactivate anyway" in place of a disabled button. The API call carries confirmOpenWorkOrders so the server-side guard is cleared deliberately rather than removed. --- .../_components/use-vendor-deactivation.ts | 17 ++++--- .../vendor-deactivation-dialog.tsx | 41 ++++++++------- src/domain/vendors/api/vendors-api.ts | 7 ++- .../vendors/use-cases/use-delete-vendor.ts | 10 +++- .../(protected)/vendors/vendors-list.test.tsx | 51 ++++++++++++++++--- 5 files changed, 92 insertions(+), 34 deletions(-) diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts index b63291be..5870f7db 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -38,13 +38,18 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio const confirm = () => { if (!target || target.id == null) return; setError(null); - deleteVendor.mutate(target.id, { - onSuccess: () => { - setTarget(null); - onSuccess?.(); + // The dialog has shown whatever open work orders exist, so confirming here is + // the explicit confirmation the API requires to deactivate past them (SH-254). + deleteVendor.mutate( + { id: target.id, confirmOpenWorkOrders: (impact?.openWorkOrders.length ?? 0) > 0 }, + { + onSuccess: () => { + setTarget(null); + onSuccess?.(); + }, + onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), }, - onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), - }); + ); }; return { diff --git a/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx index cf673b6c..a56ca3b5 100644 --- a/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx +++ b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx @@ -8,11 +8,13 @@ import { DialogContent, DialogContentText, DialogTitle, + Link, List, ListItem, Stack, Typography, } from "@mui/material"; +import { Link as RouterLink } from "react-router"; import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor"; interface VendorDeactivationDialogProps { @@ -26,6 +28,15 @@ interface VendorDeactivationDialogProps { onConfirm: () => void; } +function deactivationMessage(companyName: string | undefined, openCount: number): string { + const name = companyName ? `"${companyName}"` : "This vendor"; + if (openCount === 0) { + return `${name} will no longer be selectable for new work orders.`; + } + const plural = openCount === 1 ? "work order" : "work orders"; + return `${name} will no longer be selectable for new work orders. It still has ${openCount} open ${plural} — they'll keep it as-is unless you reassign them.`; +} + export function VendorDeactivationDialog({ target, isLoading, @@ -36,16 +47,16 @@ export function VendorDeactivationDialog({ onClose, onConfirm, }: VendorDeactivationDialogProps) { - const hasBlockingImpact = Boolean(impact && !impact.canDeactivate); + const openWorkOrders = impact?.openWorkOrders ?? []; + const hasOpenWorkOrders = openWorkOrders.length > 0; return ( - Deactivate Vendor + Deactivate this vendor? - Deactivate "{target?.companyName}"? Existing work-order and audit history will - be preserved. + {deactivationMessage(target?.companyName, openWorkOrders.length)} {isLoading && ( @@ -63,19 +74,13 @@ export function VendorDeactivationDialog({ )} - {impact != null && !isLoading && !impact.canDeactivate && ( - - This vendor cannot be deactivated because it still has open work orders. - - )} - - {impact != null && impact.openWorkOrders.length > 0 && ( + {hasOpenWorkOrders && ( - Open work orders ({impact.openWorkOrders.length}) + Open work orders ({openWorkOrders.length}) - {impact.openWorkOrders.map((wo) => ( + {openWorkOrders.map((wo) => ( - + {wo.workOrderNumber ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` : wo.workOrderTitle || `Work order ${wo.workOrderId}`} - + {Boolean(wo.status) && ( {wo.status} @@ -113,12 +118,12 @@ export function VendorDeactivationDialog({ Cancel diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index 467a3a3b..5afbc13e 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -101,8 +101,11 @@ export const vendorsApi = { return mapVendor(handleApiResponse(data)); }, - delete: async (id: string | number): Promise => { - await apiDelete(`${API_PATHS.rest.vendors}/${id}`); + // SH-254: confirmOpenWorkOrders tells the API the caller has been shown the + // vendor's open work orders and chose to proceed. Without it the API still blocks. + delete: async (id: string | number, confirmOpenWorkOrders = false): Promise => { + const suffix = confirmOpenWorkOrders ? "?confirmOpenWorkOrders=true" : ""; + await apiDelete(`${API_PATHS.rest.vendors}/${id}${suffix}`); }, getDeactivationImpact: async (id: string | number): Promise => { diff --git a/src/domain/vendors/use-cases/use-delete-vendor.ts b/src/domain/vendors/use-cases/use-delete-vendor.ts index 0457cc7e..17f8ae11 100644 --- a/src/domain/vendors/use-cases/use-delete-vendor.ts +++ b/src/domain/vendors/use-cases/use-delete-vendor.ts @@ -3,11 +3,17 @@ import { toast } from "react-toastify"; import { vendorsApi } from "@/domain/vendors/api/vendors-api"; import { queryKeys } from "@/infra/query-key/query-key"; -export function useDeleteVendor(): UseMutationResult { +export interface DeleteVendorInput { + id: string | number; + confirmOpenWorkOrders?: boolean; +} + +export function useDeleteVendor(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (id: string | number) => vendorsApi.delete(id), + mutationFn: ({ id, confirmOpenWorkOrders = false }: DeleteVendorInput) => + vendorsApi.delete(id, confirmOpenWorkOrders), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); toast.success("Vendor deactivated"); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index cbfa47f9..ae5a18d3 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -177,7 +177,7 @@ describe("VendorsListPage", () => { expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument(); }); - it("blocks deactivation when the preflight reports open work orders", async () => { + it("offers deactivate-anyway when the preflight reports open work orders", async () => { setupDefaults(); useVendorCompanyRoster.mockReturnValue({ data: activeRoster, @@ -213,13 +213,52 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("switch", { name: "Active status" })); expect( - screen.getByText(/cannot be deactivated because it still has open work orders/), + screen.getByText( + /will no longer be selectable for new work orders\. It still has 1 open work order —/, + ), ).toBeInTheDocument(); - expect(screen.getByText(/Boiler repair/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); - expect(mutate).not.toHaveBeenCalled(); + expect(screen.getByRole("link", { name: /Boiler repair/ })).toHaveAttribute( + "href", + "/workorders/101", + ); + + const confirm = screen.getByRole("button", { name: "Deactivate anyway" }); + expect(confirm).toBeEnabled(); + await userEvent.click(confirm); + + expect(mutate).toHaveBeenCalledWith({ id: 1, confirmOpenWorkOrders: true }, expect.anything()); }); + it("deactivates without the confirmation flag when nothing is linked", async () => { + setupDefaults(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + useVendorDeactivationImpact.mockReturnValue({ + data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] }, + isLoading: false, + error: null, + }); + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + + expect(screen.queryByText(/It still has/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /^Deactivate$/ })); + + expect(mutate).toHaveBeenCalledWith({ id: 1, confirmOpenWorkOrders: false }, expect.anything()); + }, 10_000); + it("preserves inline edits when deactivation is cancelled", async () => { setupDefaults(); useVendorCompanyRoster.mockReturnValue({ @@ -247,7 +286,7 @@ describe("VendorsListPage", () => { await userEvent.type(company, "Draft Company Name"); await userEvent.click(screen.getByRole("switch", { name: "Active status" })); await userEvent.click( - within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", { + within(screen.getByRole("dialog", { name: "Deactivate this vendor?" })).getByRole("button", { name: "Cancel", }), ); From 670d03d5728547dcb124fcc411b22d72d2abae87 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 19 Aug 2026 14:29:01 -0300 Subject: [PATCH 08/20] test(vendors): update the deactivation e2e to the confirmation flow (SH-254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser smoke encoded the behaviour this ticket changes: it asserted the Deactivate button was disabled when work orders were linked. It now asserts the opposite — the work orders are listed as links, "Deactivate anyway" is enabled, and confirming sends confirmOpenWorkOrders=true. Also fixes the mock's DELETE route, whose anchored pattern stopped matching once the request carried a query string. The raced-409 case is unchanged and still preserves the vendor. --- e2e/vendors/vendors.spec.ts | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index ad1a89e8..181711d6 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -66,6 +66,7 @@ interface MockState { createdBody?: Record; updatedBody?: Record; deletedId?: string; + deleteConfirmedOpenWorkOrders?: boolean; } async function fulfillJson(route: Route, body: unknown, status = 200) { @@ -259,8 +260,9 @@ async function mockVendorApi( }, }), ); - await page.route(/\/api\/vendors\/\d+$/, async (route) => { - const id = route.request().url().split("/").pop() ?? ""; + await page.route(/\/api\/vendors\/\d+(\?.*)?$/, async (route) => { + const requestUrl = new URL(route.request().url()); + const id = requestUrl.pathname.split("/").pop() ?? ""; if (route.request().method() === "PUT") { state.updatedBody = route.request().postDataJSON(); const vendor = vendorRecords.find((item) => String(item.Id) === id); @@ -273,6 +275,8 @@ async function mockVendorApi( return; } if (route.request().method() === "DELETE") { + state.deleteConfirmedOpenWorkOrders = + requestUrl.searchParams.get("confirmOpenWorkOrders") === "true"; if (options.deleteConflict) { await fulfillJson( route, @@ -517,20 +521,28 @@ test.describe("Vendor directory prototype parity", () => { await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0); }); - test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({ + test("confirms deactivation past linked work orders and preserves the vendor on a raced 409", async ({ page, }) => { - const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); + const confirmState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); await page.getByRole("switch", { name: "Active status" }).click(); - const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); - await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); - await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); - expect(blockedState.deletedId).toBeUndefined(); + const dialog = page.getByRole("dialog", { name: "Deactivate this vendor?" }); + await expect(dialog).toContainText("It still has 1 open work order"); + await expect( + dialog.getByRole("link", { name: /WO-501 — Emergency boiler repair/ }), + ).toHaveAttribute("href", "/workorders/501"); + + // SH-254: the open work orders inform the decision, they no longer block it. + const confirm = dialog.getByRole("button", { name: "Deactivate anyway" }); + await expect(confirm).toBeEnabled(); + await confirm.click(); + + await expect.poll(() => confirmState.deletedId).toBe("1"); + expect(confirmState.deleteConfirmedOpenWorkOrders).toBe(true); - await blockedDialog.getByRole("button", { name: "Cancel" }).click(); await page.unrouteAll({ behavior: "wait" }); const racedState = await mockVendorApi(page, { deleteConflict: true }); @@ -538,13 +550,13 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); await page.getByRole("switch", { name: "Active status" }).click(); await page - .getByRole("dialog", { name: "Deactivate Vendor" }) + .getByRole("dialog", { name: "Deactivate this vendor?" }) .getByRole("button", { name: /^Deactivate$/, }) .click(); - await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( + await expect(page.getByRole("dialog", { name: "Deactivate this vendor?" })).toContainText( /open work orders|conflict/i, ); expect(racedState.deletedId).toBeUndefined(); From e3b8360a68a24d5daca93dd7f03c2ec4eaf8eae0 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 15:04:50 -0300 Subject: [PATCH 09/20] fix(work-orders): treat Canceled and Refused dispatches as inactive (SH-183) Backend returns Cancelled, Canceled, and Refused; only Cancelled was skipped, so Complete stayed enabled with no live vendor. --- .../work-orders/utils/wo-closability.ts | 11 ++++-- .../work-orders/utils/wo-closability.test.ts | 36 +++++++++++++------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index ab9f9494..822f23d5 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -157,12 +157,19 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl }; } -/** Current vendor assignment — cancelled dispatches do not count as company (SH-183). */ +/** Backend terminal-cancel spellings; dispatch status is a raw API string. */ +const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]); + +function isInactiveDispatchStatus(status: string | undefined): boolean { + return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase()); +} + +/** Current vendor assignment — cancelled/refused dispatches do not count as company (SH-183). */ function currentAssignedDispatch( dispatches: WorkOrderDispatchSummary[], ): WorkOrderDispatchSummary | undefined { return dispatches.find((dispatch) => { - if (dispatch.status === "Cancelled") { + if (isInactiveDispatchStatus(dispatch.status)) { return false; } const vendorId = String(dispatch.vendorId ?? "").trim(); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 9aa2c15e..c50b27fb 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -327,8 +327,30 @@ describe("detailToClosabilityInput", () => { expect(getClosabilityGaps(input)).toEqual([]); }); - it("ignores cancelled dispatches when resolving assigned company (SH-183)", () => { - const cancelledOnly = { + it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])( + "ignores inactive dispatch status %s when resolving assigned company (SH-183)", + (status) => { + const inactiveOnly = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "HVAC PM", + source: "procurement-integration", + type: "", + docStatus: "", + mediaCount: 0, + dispatches: [{ vendorName: "Old Co", vendorId: 45, status }], + } as WorkOrderDetail; + + expect(detailToClosabilityInput(inactiveOnly).vendorId).toBe(""); + expect(detailToClosabilityInput(inactiveOnly).company).toBe(""); + expect(getClosabilityGaps(detailToClosabilityInput(inactiveOnly))).toContain("Company"); + }, + ); + + it("uses the active dispatch after an inactive cancelled spelling (SH-183)", () => { + const supersededThenCurrent = { assignedTo: "Alice", completedDate: "2026-07-15", workOrderNumber: "20260623001", @@ -338,16 +360,8 @@ describe("detailToClosabilityInput", () => { type: "", docStatus: "", mediaCount: 0, - dispatches: [{ vendorName: "", vendorId: 45, status: "Cancelled" }], - } as WorkOrderDetail; - - expect(detailToClosabilityInput(cancelledOnly).vendorId).toBe(""); - expect(getClosabilityGaps(detailToClosabilityInput(cancelledOnly))).toContain("Company"); - - const supersededThenCurrent = { - ...cancelledOnly, dispatches: [ - { vendorName: "Old Co", vendorId: 1, status: "Cancelled" }, + { vendorName: "Old Co", vendorId: 1, status: "Canceled" }, { vendorName: "ACME Tech", vendorId: 45, status: "Sent" }, ], } as WorkOrderDetail; From 854267e213084746a91de0765af3f18d440409e1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 15:16:23 -0300 Subject: [PATCH 10/20] test(work-orders): keep Verified and Completed as live company assignment (SH-183) Cancelled/Canceled/Refused stay inactive; verified or completed dispatches still gate Complete on the vendor that did the work. --- .../work-orders/utils/wo-closability.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index c50b27fb..45182f08 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -349,6 +349,29 @@ describe("detailToClosabilityInput", () => { }, ); + it.each(["Verified", "Completed", "Sent"])( + "treats dispatch status %s as a live company assignment (SH-183)", + (status) => { + const liveAssignment = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "HVAC PM", + source: "procurement-integration", + type: "", + docStatus: "", + mediaCount: 0, + dispatches: [{ vendorName: "ACME Tech", vendorId: 45, status }], + } as WorkOrderDetail; + + const input = detailToClosabilityInput(liveAssignment); + expect(input.company).toBe("ACME Tech"); + expect(input.vendorId).toBe("45"); + expect(getClosabilityGaps(input)).toEqual([]); + }, + ); + it("uses the active dispatch after an inactive cancelled spelling (SH-183)", () => { const supersededThenCurrent = { assignedTo: "Alice", From 0c514c9bad749df1d9e1dc2edb86f6356b688905 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 10:08:34 -0300 Subject: [PATCH 11/20] feat(work-orders): add per-day board pagination (SH-241) Paginate the selected day's WOs client-side with AAP footer controls. --- e2e/work-orders/board-day-pagination.spec.ts | 124 ++++++++++++++++++ .../list/table/wo-day-pagination-footer.tsx | 80 +++++++++++ .../_components/list/table/wo-table-body.tsx | 2 +- .../_components/list/table/wo-table-shell.tsx | 17 ++- .../_components/list/table/wo-table.tsx | 21 ++- .../_hooks/use-work-orders-day-pagination.ts | 42 ++++++ .../_hooks/use-work-orders-table-data.ts | 59 +++++---- .../work-orders/types/work-order-table-row.ts | 1 + .../work-orders/utils/day-pagination.ts | 60 +++++++++ .../use-work-orders-day-pagination.test.tsx | 57 ++++++++ .../wo-day-pagination-footer.test.tsx | 31 +++++ .../work-orders/utils/day-pagination.test.ts | 75 +++++++++++ 12 files changed, 537 insertions(+), 32 deletions(-) create mode 100644 e2e/work-orders/board-day-pagination.spec.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-day-pagination.ts create mode 100644 src/domain/work-orders/utils/day-pagination.ts create mode 100644 src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx create mode 100644 src/test/app/(protected)/workorders/wo-day-pagination-footer.test.tsx create mode 100644 src/test/domain/work-orders/utils/day-pagination.test.ts diff --git a/e2e/work-orders/board-day-pagination.spec.ts b/e2e/work-orders/board-day-pagination.spec.ts new file mode 100644 index 00000000..0bb3b322 --- /dev/null +++ b/e2e/work-orders/board-day-pagination.spec.ts @@ -0,0 +1,124 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wo-day-pagination-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +function boardRow(id: number, woNumber: string, scheduledDate: string) { + return { + id, + woNumber, + workOrderType: 2, + siteCode: woNumber, + lifecycleStatusLabel: "Scheduled", + scheduledDate, + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", + }; +} + +async function mockWorkOrderBoard(page: Page) { + const wednesday = Array.from({ length: 60 }, (_, index) => + boardRow(1000 + index, `WO-${1000 + index}`, "2026-08-19"), + ); + const monday = Array.from({ length: 5 }, (_, index) => + boardRow(2000 + index, `MON-${2000 + index}`, "2026-08-17"), + ); + + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + const url = route.request().url(); + if (url.includes("workorders/board/search")) { + await fulfillJson(route, { items: [], totalCount: 0, page: 1, pageSize: 100 }); + return; + } + if (url.includes("workorders/board") && route.request().method() === "GET") { + const weekStart = new URL(url).searchParams.get("weekStart") ?? "2026-08-17"; + const scheduled = + weekStart === "2026-08-17" ? [...wednesday, ...monday] : monday.slice(0, 1); + await fulfillJson(route, { + weekStart, + weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14", + counts: { returned: scheduled.length, total: scheduled.length }, + unscheduled: [], + scheduled, + }); + return; + } + await fulfillJson(route, { data: [], items: [] }); + }, + ); +} + +test.describe("work order per-day pagination (SH-241)", () => { + test.setTimeout(60_000); + + test.beforeEach(async ({ page }) => { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + await mockWorkOrderBoard(page); + }); + + test("changes rows-per-page, hides on global filter, and resets on day or week change", async ({ + page, + }) => { + const pager = page.getByTestId("wo-day-pagination"); + await page.goto("/workorders"); + await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ + timeout: 20_000, + }); + await expect(pager).toBeVisible(); + await expect(page.getByText("Page 1 of 2")).toBeVisible(); + await expect(page.getByText("WO-1000").first()).toBeVisible(); + await expect(page.getByText("WO-1050")).toHaveCount(0); + + await page.getByLabel("Rows per page").selectOption("25"); + await expect(page.getByText("Page 1 of 3")).toBeVisible(); + await expect(page.getByText("WO-1025")).toHaveCount(0); + + await page.getByTestId("wo-day-pagination-next").evaluate((node) => { + (node as HTMLButtonElement).click(); + }); + await expect(page.getByText("Page 2 of 3")).toBeVisible(); + await expect(page.getByText("WO-1025").first()).toBeVisible(); + await expect(page.getByText("WO-1000")).toHaveCount(0); + + await page.getByRole("tab", { name: "Mon · 08/17" }).click({ force: true }); + await expect(page.getByText("Page 1 of 1")).toBeVisible(); + await expect(page.getByText("MON-2000").first()).toBeVisible(); + + await page.getByRole("button", { name: "Week 8/17–8/21" }).click({ force: true }); + await page.getByRole("button", { name: /Aug 10/ }).click({ force: true }); + await expect(page.getByTestId("wo-day-pagination")).toBeVisible(); + await expect(page.getByText("Page 1 of")).toBeVisible(); + + await page.getByRole("button", { name: "Advanced Filters" }).click(); + await page.getByRole("button", { name: /Indicators/i }).click(); + await page.getByRole("button", { name: "Rescheduled ≥ 2" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); + await expect(pager).toHaveCount(0); + + await page.getByRole("button", { name: "Clear all" }).click(); + await expect(page.getByTestId("wo-day-pagination")).toBeVisible(); + }); +}); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx b/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx new file mode 100644 index 00000000..850c7f55 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx @@ -0,0 +1,80 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { NavArrowButton } from "@/components/ui/nav-arrow-button"; +import { Text } from "@/components/ui/text"; +import { + DAY_PAGE_SIZE_OPTIONS, + formatDayPaginationSummary, + type DayPageSize, +} from "@/domain/work-orders/utils/day-pagination"; + +type WoDayPaginationFooterProps = { + dayLabel: string; + itemCount: number; + pageSize: DayPageSize; + pageIndex: number; + pageCount: number; + onPageSizeChange: (pageSize: DayPageSize) => void; + onPrev: () => void; + onNext: () => void; +}; + +export function WoDayPaginationFooter({ + dayLabel, + itemCount, + pageSize, + pageIndex, + pageCount, + onPageSizeChange, + onPrev, + onNext, +}: WoDayPaginationFooterProps) { + const canPrev = pageIndex > 0; + const canNext = pageIndex < pageCount - 1; + const pageLabel = `Page ${pageIndex + 1} of ${pageCount}`; + const summary = formatDayPaginationSummary(dayLabel, itemCount); + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx index 270943a4..fd7b5e20 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx @@ -78,7 +78,7 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo tableData.startDayDraft(group.key, group.date)} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-shell.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-shell.tsx index b7aca373..2396b7c8 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-shell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-shell.tsx @@ -8,11 +8,20 @@ const CARD_STYLE: React.CSSProperties = { boxShadow: "0 1px 3px rgba(38,34,98,.05)", }; -export function WoTableShell({ children }: { children: React.ReactNode }) { +export function WoTableShell({ + children, + footer, +}: { + children: React.ReactNode; + footer?: React.ReactNode; +}) { return ( -
-
- {children} +
+
+
{children}
+ {Boolean(footer) && ( +
{footer}
+ )}
); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index 0ac11f1a..1bd17eb0 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -11,6 +11,7 @@ import type { } from "@/domain/work-orders/types/work-order-table-row"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; +import { WoDayPaginationFooter } from "./wo-day-pagination-footer"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup"; import { useWoTableInlineCreate } from "./use-wo-table-inline-create"; @@ -68,6 +69,24 @@ function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShell ); } +function WoTableDayFooter({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) { + if (!tableData.showDayPagination) { + return undefined; + } + return ( + + ); +} + export function WoTable({ tableData, users, @@ -182,7 +201,7 @@ export function WoTable({ return ( <> - + (DEFAULT_DAY_PAGE_SIZE); + const [page, setPage] = useState(0); + + useEffect(() => { + setPage(0); + }, [weekMonday, selectedDayIso, advActive]); + + const setPageSize = useCallback((next: DayPageSize) => { + setPageSizeState(next); + setPage(0); + }, []); + + const goToPrevPage = useCallback(() => { + setPage((current) => Math.max(0, current - 1)); + }, []); + + const goToNextPage = useCallback(() => { + setPage((current) => current + 1); + }, []); + + return { + pageSize, + page, + setPageSize, + goToPrevPage, + goToNextPage, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index b931237e..1d0934f6 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -1,10 +1,15 @@ import { useMemo } from "react"; import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; +import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { + pageSelectedDayGroups, + shouldShowDayPagination, +} from "@/domain/work-orders/utils/day-pagination"; import { buildSelectedDayGroup, isDayScopedSearchEmpty, @@ -50,7 +55,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advActive, isFetching, }); - const { displayRows } = local; + const { displayRows, ...localUi } = local; const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]); @@ -84,16 +89,25 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { : [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)], [advActive, filters.selectedDayIso, filters.weekMonday, scheduled], ); + const pagination = useWorkOrdersDayPagination({ + weekMonday: filters.weekMonday, + selectedDayIso: filters.selectedDayIso, + advActive, + }); + const pagedDay = useMemo( + () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize), + [dayGroups, pagination.page, pagination.pageSize], + ); const searchActive = filters.search.trim() !== ""; - const displayGroups = dayGroups; - const dayVisibleCount = displayGroups[0]?.items.length ?? 0; + const displayGroups = pagedDay.groups; + const dayTotalCount = pagedDay.totalCount; const noSearchResults = !advActive && isDayScopedSearchEmpty({ searchActive, unassignedCount: unassigned.length, - dayItemCount: dayVisibleCount, + dayItemCount: dayTotalCount, }); const isEmptyWeek = !advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive; @@ -125,30 +139,23 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { noSearchResults, isEmptyWeek, todayKey, - draftDayKey: local.draftDayKey, - draft: local.draft, - setDraft: local.setDraft, - unassignedOpen: local.unassignedOpen, - setUnassignedOpen: local.setUnassignedOpen, - unschedOpen: local.unschedOpen, - setUnschedOpen: local.setUnschedOpen, - selectedId: local.selectedId, - setSelectedId: local.setSelectedId, + ...localUi, selectedRow, - flashId: local.flashId, - triggerFlash: local.triggerFlash, - dragViz: local.dragViz, - setDragViz: local.setDragViz, - dragState: local.dragState, - patchRow: local.patchRow, - clearRowPatch: local.clearRowPatch, - reorderRows: local.reorderRows, - endDrag: local.endDrag, - startDayDraft: local.startDayDraft, - cancelDraft: local.cancelDraft, - addRow: local.addRow, scopeTotalCount: data?.totalCount ?? 0, - totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount, + totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount, search: filters.search, + showDayPagination: shouldShowDayPagination({ + flatListMode: advActive, + isLoading, + noSearchResults, + }), + dayPageSize: pagination.pageSize, + dayPageIndex: pagedDay.pageIndex, + dayPageCount: pagedDay.pageCount, + dayItemCount: pagedDay.totalCount, + dayFooterLabel: displayGroups[0]?.label ?? "", + setDayPageSize: pagination.setPageSize, + goToPrevDayPage: pagination.goToPrevPage, + goToNextDayPage: pagination.goToNextPage, }; } diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4b066382..4594bb49 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -138,6 +138,7 @@ export type WorkOrderDayGroup = { label: string; date: string; items: WorkOrderTableRow[]; + totalCount?: number; }; export type WorkOrderTablePatch = Partial; diff --git a/src/domain/work-orders/utils/day-pagination.ts b/src/domain/work-orders/utils/day-pagination.ts new file mode 100644 index 00000000..2eb40255 --- /dev/null +++ b/src/domain/work-orders/utils/day-pagination.ts @@ -0,0 +1,60 @@ +import type { WorkOrderDayGroup } from "@/domain/work-orders/types/work-order-table-row"; + +export const DAY_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const; +export const DEFAULT_DAY_PAGE_SIZE = 50; + +export type DayPageSize = (typeof DAY_PAGE_SIZE_OPTIONS)[number]; + +export type PaginatedSlice = { + visible: T[]; + pageCount: number; + pageIndex: number; +}; + +export function paginateItems( + items: readonly T[], + pageIndex: number, + pageSize: number, +): PaginatedSlice { + const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE; + const pageCount = Math.max(1, Math.ceil(items.length / size) || 1); + const page = Math.min(Math.max(0, pageIndex), pageCount - 1); + const start = page * size; + return { + visible: items.slice(start, start + size), + pageCount, + pageIndex: page, + }; +} + +export function shouldShowDayPagination(input: { + flatListMode: boolean; + isLoading: boolean; + noSearchResults: boolean; +}): boolean { + return !input.flatListMode && !input.isLoading && !input.noSearchResults; +} + +export function formatDayPaginationSummary(dayLabel: string, itemCount: number): string { + const label = dayLabel.trim().toUpperCase(); + const noun = itemCount === 1 ? "item" : "items"; + return `${label} · ${itemCount} ${noun}`; +} + +export function pageSelectedDayGroups( + dayGroups: WorkOrderDayGroup[], + pageIndex: number, + pageSize: number, +): { groups: WorkOrderDayGroup[]; pageIndex: number; pageCount: number; totalCount: number } { + const group = dayGroups[0]; + if (!group) { + return { groups: dayGroups, pageIndex: 0, pageCount: 1, totalCount: 0 }; + } + const slice = paginateItems(group.items, pageIndex, pageSize); + return { + groups: [{ ...group, items: slice.visible, totalCount: group.items.length }], + pageIndex: slice.pageIndex, + pageCount: slice.pageCount, + totalCount: group.items.length, + }; +} diff --git a/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx new file mode 100644 index 00000000..7d4b3935 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx @@ -0,0 +1,57 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; +import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination"; + +describe("useWorkOrdersDayPagination (SH-241)", () => { + const base = { + weekMonday: "2026-08-17", + selectedDayIso: "2026-08-17", + advActive: false, + }; + + it("defaults to 50 rows per page", () => { + const { result } = renderHook(() => useWorkOrdersDayPagination(base)); + expect(result.current.pageSize).toBe(DEFAULT_DAY_PAGE_SIZE); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when switching days", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + expect(result.current.page).toBe(1); + rerender({ ...base, selectedDayIso: "2026-08-18" }); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when the week changes", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + rerender({ ...base, weekMonday: "2026-08-10" }); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when rows-per-page changes", () => { + const { result } = renderHook(() => useWorkOrdersDayPagination(base)); + act(() => result.current.goToNextPage()); + act(() => result.current.setPageSize(25)); + expect(result.current.page).toBe(0); + expect(result.current.pageSize).toBe(25); + }); + + it("resets to page 1 when a global filter is applied", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + rerender({ ...base, advActive: true }); + expect(result.current.page).toBe(0); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-day-pagination-footer.test.tsx b/src/test/app/(protected)/workorders/wo-day-pagination-footer.test.tsx new file mode 100644 index 00000000..37965136 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-day-pagination-footer.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { WoDayPaginationFooter } from "@/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer"; + +describe("WoDayPaginationFooter (SH-241)", () => { + it("changes rows-per-page and navigates pages", async () => { + const user = userEvent.setup(); + const onPageSizeChange = vi.fn(); + const onNext = vi.fn(); + render( + , + ); + expect(screen.getByText("MON · 06/01 · 60 items")).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + await user.selectOptions(screen.getByLabelText("Rows per page"), "25"); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + await user.click(screen.getByRole("button", { name: "Next page" })); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/utils/day-pagination.test.ts b/src/test/domain/work-orders/utils/day-pagination.test.ts new file mode 100644 index 00000000..0a72c026 --- /dev/null +++ b/src/test/domain/work-orders/utils/day-pagination.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + DAY_PAGE_SIZE_OPTIONS, + DEFAULT_DAY_PAGE_SIZE, + formatDayPaginationSummary, + paginateItems, + shouldShowDayPagination, +} from "@/domain/work-orders/utils/day-pagination"; + +describe("day pagination utils (SH-241)", () => { + it("defaults to 50 and exposes 25/50/100/200", () => { + expect(DEFAULT_DAY_PAGE_SIZE).toBe(50); + expect(DAY_PAGE_SIZE_OPTIONS).toEqual([25, 50, 100, 200]); + }); + + it("paginates 60 items at 50 per page", () => { + const items = Array.from({ length: 60 }, (_, index) => index); + const first = paginateItems(items, 0, 50); + expect(first.visible).toHaveLength(50); + expect(first.pageCount).toBe(2); + const second = paginateItems(items, 1, 50); + expect(second.visible).toEqual(items.slice(50)); + }); + + it("re-paginates immediately when page size changes", () => { + const items = Array.from({ length: 60 }, (_, index) => index); + expect(paginateItems(items, 0, 25).visible).toHaveLength(25); + expect(paginateItems(items, 0, 25).pageCount).toBe(3); + }); + + it("clamps an out-of-range page to the last page", () => { + const paged = paginateItems([1, 2, 3], 9, 2); + expect(paged.pageIndex).toBe(1); + expect(paged.visible).toEqual([3]); + }); + + it("shows pagination in day view even when the page is not full", () => { + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: false, + noSearchResults: false, + }), + ).toBe(true); + }); + + it("hides pagination in flat-list / loading / empty search", () => { + expect( + shouldShowDayPagination({ + flatListMode: true, + isLoading: false, + noSearchResults: false, + }), + ).toBe(false); + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: true, + noSearchResults: false, + }), + ).toBe(false); + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: false, + noSearchResults: true, + }), + ).toBe(false); + }); + + it("formats the AAP footer summary", () => { + expect(formatDayPaginationSummary("Mon · 06/01", 9)).toBe("MON · 06/01 · 9 items"); + expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item"); + }); +}); From e9b00bab57cdfa928e43429208684dc27913a129 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 10:52:43 -0300 Subject: [PATCH 12/20] fix(work-orders): ignore inactive primary dispatch on board closability (SH-183) Board Completed must not treat Cancelled/Canceled/Refused leftover vendorId as a live company assignment. --- .../list/table/cells/status-cell.tsx | 17 +------ .../mappers/work-order-table-row-mapper.ts | 13 +++++ .../types/work-order-board-detail.ts | 4 ++ .../work-orders/types/work-order-board.ts | 4 ++ .../work-orders/types/work-order-table-row.ts | 2 + .../work-orders/utils/wo-closability.ts | 23 ++++++++- .../status-cell-pending-uplift.test.tsx | 47 +++++++++++++++++++ .../work-order-table-row-mapper.test.ts | 12 +++++ .../work-orders/utils/wo-closability.test.ts | 34 ++++++++++++++ 9 files changed, 140 insertions(+), 16 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 0723150d..de7ed3c2 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -10,6 +10,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta import { formatClosabilityMissingTitle, getClosabilityGaps, + tableRowToClosabilityInput, } from "@/domain/work-orders/utils/wo-closability"; import { HoverChevron } from "./hover-chevron"; import { WoTableStatusLabel } from "./wo-table-status-label"; @@ -22,21 +23,7 @@ type StatusCellProps = { }; function rowClosabilityGaps(wo: WorkOrderTableRow) { - return getClosabilityGaps({ - dispatcherId: wo.dispatcherId, - dispatcherName: wo.dispatcherName, - company: wo.company, - vendorId: wo.vendorId ?? "", - tech: wo.tech, - completedDate: wo.completedDate ?? "", - woNumber: wo.woNumber, - pm: wo.pm, - docStatus: wo.docStatus, - mediaCount: wo.mediaCount, - type: wo.type, - upliftSummary: wo.upliftSummary, - hasPendingUplift: wo.hasPendingUplift, - }); + return getClosabilityGaps(tableRowToClosabilityInput(wo)); } function PastDueStatusCell({ diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 8dfe70e0..b17b683a 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -267,6 +267,18 @@ function readHasPendingUplift( return undefined; } +function readPrimaryDispatchStatus(item: Record): string { + return readString( + item, + "primaryDispatchStatus", + "PrimaryDispatchStatus", + "dispatchStatus", + "DispatchStatus", + "vendorDispatchStatus", + "VendorDispatchStatus", + ); +} + export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const item = asRecord(raw); const scheduledOn = toDateInputValue( @@ -348,6 +360,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { extraServices: readStringArray(item, "extraServices", "ExtraServices"), vendorId: vendorIdNum != null ? String(vendorIdNum) : "", company: readString(item, "company", "Company", "vendorName", "VendorName"), + primaryDispatchStatus: readPrimaryDispatchStatus(item), tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"), techPhone: readString(item, "techPhone", "TechPhone"), apptStart, diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts index dda17df8..14adb595 100644 --- a/src/domain/work-orders/types/work-order-board-detail.ts +++ b/src/domain/work-orders/types/work-order-board-detail.ts @@ -42,6 +42,10 @@ export interface WorkOrderBoardDetailInfoApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; + /** Primary dispatch status when the board contract supplies it (SH-183). */ + primaryDispatchStatus?: string | null; + dispatchStatus?: string | null; + vendorDispatchStatus?: string | null; techName?: string | null; techPhone?: string | null; apptDate?: string | null; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 5011b67b..d8c178c4 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -184,6 +184,10 @@ export interface WorkOrderBoardRowApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; + /** Primary dispatch status when the board contract supplies it (SH-183). */ + primaryDispatchStatus?: string | null; + dispatchStatus?: string | null; + vendorDispatchStatus?: string | null; techName?: string | null; techPhone?: string | null; apptDate?: string | null; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4b066382..e6432ab7 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -37,6 +37,8 @@ export interface WorkOrderTableRow { extraServices: string[]; vendorId: string; company: string; + /** Primary dispatch status when the board contract supplies it (SH-183). */ + primaryDispatchStatus?: string; tech: string; techPhone: string; apptStart: string; diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 822f23d5..9feb2db4 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -2,6 +2,7 @@ import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispat import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; import type { CompletionDocStatus, + WorkOrderTableRow, WorkOrderUpliftSummary, } from "@/domain/work-orders/types/work-order-table-row"; import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; @@ -160,10 +161,30 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl /** Backend terminal-cancel spellings; dispatch status is a raw API string. */ const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]); -function isInactiveDispatchStatus(status: string | undefined): boolean { +export function isInactiveDispatchStatus(status: string | undefined): boolean { return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase()); } +/** Map board row onto closability; inactive primary dispatch is not a live company (SH-183). */ +export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosabilityInput { + const inactive = isInactiveDispatchStatus(wo.primaryDispatchStatus); + return { + dispatcherId: wo.dispatcherId, + dispatcherName: wo.dispatcherName, + company: inactive ? "" : wo.company, + vendorId: inactive ? "" : (wo.vendorId ?? ""), + tech: wo.tech, + completedDate: wo.completedDate ?? "", + woNumber: wo.woNumber, + pm: wo.pm, + docStatus: wo.docStatus, + mediaCount: wo.mediaCount, + type: wo.type, + upliftSummary: wo.upliftSummary, + hasPendingUplift: wo.hasPendingUplift, + }; +} + /** Current vendor assignment — cancelled/refused dispatches do not count as company (SH-183). */ function currentAssignedDispatch( dispatches: WorkOrderDispatchSummary[], diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx index 577c1469..4dead871 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -159,6 +159,53 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).toHaveBeenCalledWith("Completed"); }); + it("allows Completed when vendorId has an empty company label and a live dispatch status (SH-183)", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + }); + + it.each(["Cancelled", "Canceled", "Refused"] as const)( + "disables Completed when vendorId is leftover from a %s dispatch (SH-183)", + (primaryDispatchStatus) => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + + const completed = screen.getByRole("button", { name: /completed/i }); + expect(completed).toBeDisabled(); + expect(completed).toHaveAttribute("title", "Missing: Company"); + fireEvent.click(completed); + expect(onChangeStatus).not.toHaveBeenCalled(); + }, + ); + it("allows Completed when technician is empty if company is set (SH-183)", () => { const onChangeStatus = vi.fn(); diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 93123601..a2fbd50c 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -50,3 +50,15 @@ describe("mapWorkOrderTableRow isAddOn", () => { expect(row.isAddOn).toBe(false); }); }); + +describe("mapWorkOrderTableRow primaryDispatchStatus", () => { + it.each([ + ["primaryDispatchStatus", "Refused"], + ["dispatchStatus", "Canceled"], + ["vendorDispatchStatus", "Cancelled"], + ] as const)("maps %s onto primaryDispatchStatus", (key, value) => { + const row = mapWorkOrderTableRow({ id: 9, vendorId: 45, [key]: value }); + expect(row.vendorId).toBe("45"); + expect(row.primaryDispatchStatus).toBe(value); + }); +}); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 45182f08..d7eaf16b 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { readDetailWoType } from "@/domain/work-orders/utils/read-detail-closability-fields"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; import { @@ -13,6 +14,7 @@ import { hasPendingUplift, isCompletionStatusLabel, isRealWoNumber, + tableRowToClosabilityInput, wizardDraftToClosabilityInput, } from "@/domain/work-orders/utils/wo-closability"; @@ -396,6 +398,38 @@ describe("detailToClosabilityInput", () => { }); }); +describe("tableRowToClosabilityInput", () => { + const closableRow = { + dispatcherId: "u1", + dispatcherName: "Alex", + company: "", + vendorId: "45", + tech: "", + completedDate: "2026-07-15", + woNumber: "20260623001", + pm: "HVAC PM", + docStatus: "", + mediaCount: 0, + type: "", + } as WorkOrderTableRow; + + it("keeps vendorId as a live assignment when primary dispatch status is absent (SH-183)", () => { + const input = tableRowToClosabilityInput(closableRow); + expect(input.vendorId).toBe("45"); + expect(getClosabilityGaps(input)).toEqual([]); + }); + + it.each(["Cancelled", "Canceled", "Refused"] as const)( + "clears company assignment when primary dispatch status is %s (SH-183)", + (primaryDispatchStatus) => { + const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus }); + expect(input.company).toBe(""); + expect(input.vendorId).toBe(""); + expect(getClosabilityGaps(input)).toContain("Company"); + }, + ); +}); + describe("readDetailWoType", () => { it.each(["PM", "Reactive", "Emergency", "Overdue"])( "passes through the business type %s", From ee5eae89275fc1dd8716c529e1a0cde569f6312a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 11:00:25 -0300 Subject: [PATCH 13/20] fix(work-orders): page-aware flash and Unassigned search empty (SH-241) Empty search waits until the Unassigned queue has settled. Reschedule selects the destination day's page before flashing the row. --- .../list/table/use-wo-table-row-handlers.ts | 14 +- .../_components/list/table/wo-table.tsx | 8 +- .../_hooks/use-work-orders-day-pagination.ts | 32 ++++- .../_hooks/use-work-orders-table-data.ts | 5 + .../work-orders/utils/day-pagination.ts | 20 +++ .../work-orders/utils/week-day-nav-utils.ts | 15 ++- ...se-wo-table-row-handlers-duplicate.test.ts | 1 + ...use-wo-table-row-handlers-schedule.test.ts | 14 +- .../use-work-orders-day-pagination.test.tsx | 20 ++- .../workorders/wo-table-search-empty.test.tsx | 123 ++++++++++++++++++ .../work-orders/utils/day-pagination.test.ts | 9 ++ .../utils/week-day-nav-utils.test.ts | 43 +++++- 12 files changed, 286 insertions(+), 18 deletions(-) create mode 100644 src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index aa6b8599..98374174 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -36,7 +36,13 @@ export function useWoTableRowHandlers({ onScheduleHighlight, onDuplicateFound, }: UseWoTableRowHandlersOptions): WoTableRowHandlers { - const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, triggerFlash } = tableData; + const { + dragState: dragStateRef, + setDragViz, + reorderRows, + endDrag, + revealScheduledRow, + } = tableData; return useMemo( () => ({ @@ -77,13 +83,13 @@ export function useWoTableRowHandlers({ rescheduleNotify(date, { onNavigate: () => { onNavigateToDay(date); - setTimeout(() => triggerFlash(id), 180); + revealScheduledRow(id); }, }); return; } onNavigateToDay(date); - triggerFlash(id); + revealScheduledRow(id); }, onScheduleWeek: (id: string | number, week: string) => { onPatch(id, { @@ -132,7 +138,7 @@ export function useWoTableRowHandlers({ setDragViz, reorderRows, endDrag, - triggerFlash, + revealScheduledRow, weekMonday, onNavigateToDay, onOpenAdvancedFilters, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index 1bd17eb0..0d170a5d 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -9,6 +9,7 @@ import type { WorkOrderTablePatch, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; +import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; import { WoDayPaginationFooter } from "./wo-day-pagination-footer"; @@ -189,7 +190,12 @@ export function WoTable({ ); } - if (!tableData.advActive && tableData.noSearchResults) { + const showSearchEmpty = + !tableData.advActive && + tableData.noSearchResults && + isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError); + + if (showSearchEmpty) { return ( void; }; export function useWorkOrdersDayPagination({ weekMonday, selectedDayIso, advActive, + dayItems, + onRevealed, }: UseWorkOrdersDayPaginationInput) { const [pageSize, setPageSizeState] = useState(DEFAULT_DAY_PAGE_SIZE); const [page, setPage] = useState(0); + const [pendingRevealId, setPendingRevealId] = useState(null); useEffect(() => { setPage(0); }, [weekMonday, selectedDayIso, advActive]); + useEffect(() => { + if (pendingRevealId == null) { + return; + } + const nextPage = findItemPageIndex(dayItems, pendingRevealId, pageSize); + if (nextPage == null) { + return; + } + setPage(nextPage); + const revealedId = pendingRevealId; + setPendingRevealId(null); + onRevealed(revealedId); + }, [pendingRevealId, dayItems, pageSize, onRevealed]); + const setPageSize = useCallback((next: DayPageSize) => { setPageSizeState(next); setPage(0); @@ -32,11 +57,16 @@ export function useWorkOrdersDayPagination({ setPage((current) => current + 1); }, []); + const revealRow = useCallback((id: string | number) => { + setPendingRevealId(id); + }, []); + return { pageSize, page, setPageSize, goToPrevPage, goToNextPage, + revealRow, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 1d0934f6..0b02f8a5 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -13,6 +13,7 @@ import { import { buildSelectedDayGroup, isDayScopedSearchEmpty, + isUnassignedQueueReady, } from "@/domain/work-orders/utils/week-day-nav-utils"; import { composeBoardSections, @@ -93,6 +94,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { weekMonday: filters.weekMonday, selectedDayIso: filters.selectedDayIso, advActive, + dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS, + onRevealed: local.triggerFlash, }); const pagedDay = useMemo( () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize), @@ -108,6 +111,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { searchActive, unassignedCount: unassigned.length, dayItemCount: dayTotalCount, + unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError), }); const isEmptyWeek = !advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive; @@ -157,5 +161,6 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { setDayPageSize: pagination.setPageSize, goToPrevDayPage: pagination.goToPrevPage, goToNextDayPage: pagination.goToNextPage, + revealScheduledRow: pagination.revealRow, }; } diff --git a/src/domain/work-orders/utils/day-pagination.ts b/src/domain/work-orders/utils/day-pagination.ts index 2eb40255..8caab266 100644 --- a/src/domain/work-orders/utils/day-pagination.ts +++ b/src/domain/work-orders/utils/day-pagination.ts @@ -11,6 +11,26 @@ export type PaginatedSlice = { pageIndex: number; }; +export function pageIndexForItem(itemIndex: number, pageSize: number): number { + if (itemIndex < 0) { + return 0; + } + const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE; + return Math.floor(itemIndex / size); +} + +export function findItemPageIndex( + items: readonly T[], + id: string | number, + pageSize: number, +): number | null { + const index = items.findIndex((item) => String(item.id) === String(id)); + if (index < 0) { + return null; + } + return pageIndexForItem(index, pageSize); +} + export function paginateItems( items: readonly T[], pageIndex: number, diff --git a/src/domain/work-orders/utils/week-day-nav-utils.ts b/src/domain/work-orders/utils/week-day-nav-utils.ts index 7eea7d4b..073cf231 100644 --- a/src/domain/work-orders/utils/week-day-nav-utils.ts +++ b/src/domain/work-orders/utils/week-day-nav-utils.ts @@ -27,12 +27,25 @@ export function scheduledDayTab(dateIso: string): { weekMonday: string; selected }; } +export function isUnassignedQueueReady( + unassignedLoading: boolean, + unassignedError: Error | null, +): boolean { + return !unassignedLoading && unassignedError == null; +} + export function isDayScopedSearchEmpty(args: { searchActive: boolean; unassignedCount: number; dayItemCount: number; + unassignedQueueReady: boolean; }): boolean { - return args.searchActive && args.unassignedCount === 0 && args.dayItemCount === 0; + return ( + args.searchActive && + args.unassignedQueueReady && + args.unassignedCount === 0 && + args.dayItemCount === 0 + ); } export function initialSelectedDay(weekMonday: string, today = todayIso()): string { diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts index e9b52a4a..daad10bd 100644 --- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts @@ -13,6 +13,7 @@ function buildTableData() { endDrag: vi.fn(), setUnschedOpen: vi.fn(), triggerFlash: vi.fn(), + revealScheduledRow: vi.fn(), setSelectedId: vi.fn(), } as never; } diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts index afbf434c..0f0ed39a 100644 --- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts @@ -13,13 +13,13 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", weekMoveNotify, })); -function buildTableData(triggerFlash = vi.fn()) { +function buildTableData(revealScheduledRow = vi.fn()) { return { dragState: { current: { dragId: null, overId: null } }, setDragViz: vi.fn(), reorderRows: vi.fn(), endDrag: vi.fn(), - triggerFlash, + revealScheduledRow, setSelectedId: vi.fn(), } as never; } @@ -28,11 +28,11 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { it("selects the scheduled weekday in the same week so the row stays visible", () => { const onNavigateToDay = vi.fn(); const onPatch = vi.fn(); - const triggerFlash = vi.fn(); + const revealScheduledRow = vi.fn(); const { result } = renderHook(() => useWoTableRowHandlers({ - tableData: buildTableData(triggerFlash), + tableData: buildTableData(revealScheduledRow), weekMonday: "2026-05-18", onNavigateToDay, onOpenSlideOver: vi.fn(), @@ -46,16 +46,17 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false)); expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21"); - expect(triggerFlash).toHaveBeenCalledWith("wo-1"); + expect(revealScheduledRow).toHaveBeenCalledWith("wo-1"); expect(rescheduleNotify).not.toHaveBeenCalled(); }); it("sends cross-week view-it navigation to the target day, not Monday", () => { const onNavigateToDay = vi.fn(); + const revealScheduledRow = vi.fn(); const { result } = renderHook(() => useWoTableRowHandlers({ - tableData: buildTableData(), + tableData: buildTableData(revealScheduledRow), weekMonday: "2026-05-18", onNavigateToDay, onOpenSlideOver: vi.fn(), @@ -76,6 +77,7 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void }; onNavigate(); expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27"); + expect(revealScheduledRow).toHaveBeenCalledWith("wo-1"); }); it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => { diff --git a/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx index 7d4b3935..01e98569 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx +++ b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx @@ -1,13 +1,16 @@ import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination"; describe("useWorkOrdersDayPagination (SH-241)", () => { + const onRevealed = vi.fn(); const base = { weekMonday: "2026-08-17", selectedDayIso: "2026-08-17", advActive: false, + dayItems: [] as { id: string }[], + onRevealed, }; it("defaults to 50 rows per page", () => { @@ -54,4 +57,19 @@ describe("useWorkOrdersDayPagination (SH-241)", () => { rerender({ ...base, advActive: true }); expect(result.current.page).toBe(0); }); + + it("selects the page that contains a revealed row beyond the first page", () => { + const dayItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` })); + const revealed = vi.fn(); + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: { ...base, onRevealed: revealed } }, + ); + + act(() => result.current.revealRow("wo-55")); + rerender({ ...base, dayItems, onRevealed: revealed, selectedDayIso: "2026-08-18" }); + + expect(result.current.page).toBe(1); + expect(revealed).toHaveBeenCalledWith("wo-55"); + }); }); diff --git a/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx new file mode 100644 index 00000000..db7e5685 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; +import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({ + useLocationSites: () => ({ data: [] }), +})); + +vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({ + useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }), +})); + +function tableData( + overrides: Partial = {}, +): ReturnTypeOfUseWorkOrdersTableData { + return { + rows: [], + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + advActive: false, + advResults: [], + unassigned: [], + unassignedError: null, + unassignedLoading: false, + unscheduled: [], + displayGroups: [{ key: "05/18", label: "Mon · 05/18", date: "2026-05-18", items: [] }], + searchActive: true, + noSearchResults: true, + isEmptyWeek: false, + todayKey: null, + draftDayKey: null, + draft: { locationId: "", site: "" }, + setDraft: vi.fn(), + unassignedOpen: true, + setUnassignedOpen: vi.fn(), + unschedOpen: true, + setUnschedOpen: vi.fn(), + selectedId: null, + setSelectedId: vi.fn(), + selectedRow: null, + flashId: null, + triggerFlash: vi.fn(), + revealScheduledRow: vi.fn(), + dragViz: null, + setDragViz: vi.fn(), + dragState: { current: { dragId: null, overId: null } }, + patchRow: vi.fn(), + clearRowPatch: vi.fn(), + reorderRows: vi.fn(), + endDrag: vi.fn(), + startDayDraft: vi.fn(), + cancelDraft: vi.fn(), + addRow: vi.fn(), + scopeTotalCount: 0, + totalCount: 0, + search: "no-match", + showDayPagination: false, + dayPageSize: 50, + dayPageIndex: 0, + dayPageCount: 1, + dayItemCount: 0, + dayFooterLabel: "Mon · 05/18", + setDayPageSize: vi.fn(), + goToPrevDayPage: vi.fn(), + goToNextDayPage: vi.fn(), + ...overrides, + } as ReturnTypeOfUseWorkOrdersTableData; +} + +function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) { + return renderWithProviders( + , + { withAuth: false }, + ); +} + +describe("WoTable search empty vs Unassigned queue (SH-165/SH-241)", () => { + it("shows empty search only after the Unassigned queue has settled empty", () => { + const { getByText, queryByText } = renderTable(tableData()); + + expect(getByText("No work orders match your search")).toBeInTheDocument(); + expect(queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument(); + }); + + it("keeps Unassigned loading visible instead of a premature empty search", () => { + const { getByText, queryByText } = renderTable( + tableData({ + noSearchResults: true, + unassignedLoading: true, + }), + ); + + expect(queryByText("No work orders match your search")).not.toBeInTheDocument(); + expect(getByText(/Loading unassigned work orders/)).toBeInTheDocument(); + }); + + it("keeps Unassigned error visible instead of hiding it behind empty search", () => { + const { getByText, queryByText } = renderTable( + tableData({ + noSearchResults: true, + unassignedError: new Error("queue unavailable"), + }), + ); + + expect(queryByText("No work orders match your search")).not.toBeInTheDocument(); + expect(getByText("queue unavailable")).toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/work-orders/utils/day-pagination.test.ts b/src/test/domain/work-orders/utils/day-pagination.test.ts index 0a72c026..0a71ba3c 100644 --- a/src/test/domain/work-orders/utils/day-pagination.test.ts +++ b/src/test/domain/work-orders/utils/day-pagination.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { DAY_PAGE_SIZE_OPTIONS, DEFAULT_DAY_PAGE_SIZE, + findItemPageIndex, formatDayPaginationSummary, + pageIndexForItem, paginateItems, shouldShowDayPagination, } from "@/domain/work-orders/utils/day-pagination"; @@ -68,6 +70,13 @@ describe("day pagination utils (SH-241)", () => { ).toBe(false); }); + it("maps a row past the first page onto page 2 at the default size", () => { + const items = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` })); + expect(pageIndexForItem(50, 50)).toBe(1); + expect(findItemPageIndex(items, "wo-55", 50)).toBe(1); + expect(findItemPageIndex(items, "missing", 50)).toBeNull(); + }); + it("formats the AAP footer summary", () => { expect(formatDayPaginationSummary("Mon · 06/01", 9)).toBe("MON · 06/01 · 9 items"); expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item"); diff --git a/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts b/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts index bc56591d..0439dc61 100644 --- a/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts +++ b/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts @@ -7,6 +7,7 @@ import { formatDayTabLabel, initialSelectedDay, isDayScopedSearchEmpty, + isUnassignedQueueReady, rowMatchesSelectedDay, scheduledDayTab, weekdaysMonFri, @@ -131,16 +132,50 @@ describe("week-day-nav-utils (SH-240)", () => { it("treats search as empty only when the selected day and Unassigned have no hits", () => { expect( - isDayScopedSearchEmpty({ searchActive: true, unassignedCount: 0, dayItemCount: 0 }), + isDayScopedSearchEmpty({ + searchActive: true, + unassignedCount: 0, + dayItemCount: 0, + unassignedQueueReady: true, + }), ).toBe(true); expect( - isDayScopedSearchEmpty({ searchActive: true, unassignedCount: 0, dayItemCount: 1 }), + isDayScopedSearchEmpty({ + searchActive: true, + unassignedCount: 0, + dayItemCount: 1, + unassignedQueueReady: true, + }), ).toBe(false); expect( - isDayScopedSearchEmpty({ searchActive: true, unassignedCount: 2, dayItemCount: 0 }), + isDayScopedSearchEmpty({ + searchActive: true, + unassignedCount: 2, + dayItemCount: 0, + unassignedQueueReady: true, + }), ).toBe(false); expect( - isDayScopedSearchEmpty({ searchActive: false, unassignedCount: 0, dayItemCount: 0 }), + isDayScopedSearchEmpty({ + searchActive: false, + unassignedCount: 0, + dayItemCount: 0, + unassignedQueueReady: true, + }), + ).toBe(false); + }); + + it("does not treat search as empty while the Unassigned queue is loading or failed", () => { + expect(isUnassignedQueueReady(true, null)).toBe(false); + expect(isUnassignedQueueReady(false, new Error("queue unavailable"))).toBe(false); + expect(isUnassignedQueueReady(false, null)).toBe(true); + expect( + isDayScopedSearchEmpty({ + searchActive: true, + unassignedCount: 0, + dayItemCount: 0, + unassignedQueueReady: false, + }), ).toBe(false); }); }); From f77428ab19167e231340c160884fe7a7266e3740 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 13:22:12 -0300 Subject: [PATCH 14/20] fix(work-orders): map only official primaryDispatchStatus (SH-183) Drop undocumented board aliases so the Completed gate matches the backend DTO field. --- .../mappers/work-order-table-row-mapper.ts | 10 +--------- .../work-orders/types/work-order-board-detail.ts | 4 +--- src/domain/work-orders/types/work-order-board.ts | 4 +--- .../mappers/work-order-table-row-mapper.test.ts | 15 ++++++++++++--- .../work-orders/utils/wo-closability.test.ts | 6 ++++++ 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index b17b683a..cea22e59 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -268,15 +268,7 @@ function readHasPendingUplift( } function readPrimaryDispatchStatus(item: Record): string { - return readString( - item, - "primaryDispatchStatus", - "PrimaryDispatchStatus", - "dispatchStatus", - "DispatchStatus", - "vendorDispatchStatus", - "VendorDispatchStatus", - ); + return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus"); } export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts index 14adb595..163775e9 100644 --- a/src/domain/work-orders/types/work-order-board-detail.ts +++ b/src/domain/work-orders/types/work-order-board-detail.ts @@ -42,10 +42,8 @@ export interface WorkOrderBoardDetailInfoApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; - /** Primary dispatch status when the board contract supplies it (SH-183). */ + /** Exact board DTO field; inactive primary dispatch is not a live company (SH-183). */ primaryDispatchStatus?: string | null; - dispatchStatus?: string | null; - vendorDispatchStatus?: string | null; techName?: string | null; techPhone?: string | null; apptDate?: string | null; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index d8c178c4..bc836d30 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -184,10 +184,8 @@ export interface WorkOrderBoardRowApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; - /** Primary dispatch status when the board contract supplies it (SH-183). */ + /** Exact board DTO field; inactive primary dispatch is not a live company (SH-183). */ primaryDispatchStatus?: string | null; - dispatchStatus?: string | null; - vendorDispatchStatus?: string | null; techName?: string | null; techPhone?: string | null; apptDate?: string | null; diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index a2fbd50c..99283be9 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -54,11 +54,20 @@ describe("mapWorkOrderTableRow isAddOn", () => { describe("mapWorkOrderTableRow primaryDispatchStatus", () => { it.each([ ["primaryDispatchStatus", "Refused"], - ["dispatchStatus", "Canceled"], - ["vendorDispatchStatus", "Cancelled"], - ] as const)("maps %s onto primaryDispatchStatus", (key, value) => { + ["PrimaryDispatchStatus", "Canceled"], + ] as const)("maps exact DTO field %s onto primaryDispatchStatus", (key, value) => { const row = mapWorkOrderTableRow({ id: 9, vendorId: 45, [key]: value }); expect(row.vendorId).toBe("45"); expect(row.primaryDispatchStatus).toBe(value); }); + + it("ignores undocumented dispatchStatus aliases", () => { + const row = mapWorkOrderTableRow({ + id: 9, + vendorId: 45, + dispatchStatus: "Refused", + vendorDispatchStatus: "Cancelled", + }); + expect(row.primaryDispatchStatus).toBe(""); + }); }); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index d7eaf16b..c463dd5f 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -428,6 +428,12 @@ describe("tableRowToClosabilityInput", () => { expect(getClosabilityGaps(input)).toContain("Company"); }, ); + + it("raises Company when the board omits vendor assignment (inactive primary, SH-183)", () => { + const input = tableRowToClosabilityInput({ ...closableRow, vendorId: "", company: "" }); + expect(input.vendorId).toBe(""); + expect(getClosabilityGaps(input)).toContain("Company"); + }); }); describe("readDetailWoType", () => { From 4c9f8b7d81ca26fb9fa8e4d9f9a06aa2fa492f32 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 14:17:20 -0300 Subject: [PATCH 15/20] fix(work-orders): treat vendor save as a live company assignment Clear leftover primary dispatch status on vendor patch so Completed is not blocked after a new company is chosen. Keep Jira keys out of source comments. --- REVIEW_AND_PR_FRAMEWORK.md | 4 ++ .../tabs/slide-over-info-tab-edit-view.tsx | 10 +--- .../list/table/wo-table-inline-row-cells.tsx | 10 +--- .../list/table/wo-table-row-service-cells.tsx | 10 +--- .../types/work-order-board-detail.ts | 1 - .../work-orders/types/work-order-board.ts | 1 - .../work-orders/types/work-order-table-row.ts | 1 - .../enrich-detail-closability-from-board.ts | 2 +- .../utils/vendor-assignment-patch.ts | 18 ++++++ .../work-orders/utils/wo-closability.ts | 7 +-- .../status-cell-pending-uplift.test.tsx | 57 +++++++++++++++++-- .../work-order-table-row-mapper.test.ts | 22 +++++-- .../utils/vendor-assignment-patch.test.ts | 37 ++++++++++++ .../work-orders/utils/wo-closability.test.ts | 37 ++++++++---- 14 files changed, 164 insertions(+), 53 deletions(-) create mode 100644 src/domain/work-orders/utils/vendor-assignment-patch.ts create mode 100644 src/test/domain/work-orders/utils/vendor-assignment-patch.test.ts diff --git a/REVIEW_AND_PR_FRAMEWORK.md b/REVIEW_AND_PR_FRAMEWORK.md index 977582f4..3496a066 100644 --- a/REVIEW_AND_PR_FRAMEWORK.md +++ b/REVIEW_AND_PR_FRAMEWORK.md @@ -63,6 +63,10 @@ a status or mark unverified work Done. - **MUST NOT** leave comments that only restate what Prettier or ESLint already enforces (formatting, naming nits the linter catches). Style is settled by the gates; review is for behavior, correctness, security, and architecture. +- **MUST NOT** put Jira issue keys or ticket titles in source comments, JSDoc, + or test names (for example `(SH-183)`). Ticket identity belongs in the PR, + commit message, and branch — not in the code. Flag and request removal if a + diff adds them. - **MUST** make every comment actionable: tie it to a behavior, a risk, or an evidence-based convention in these docs, and offer a concrete fix or a targeted question. Use GitHub suggestion blocks when safe. diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index bdfe8af9..8b92c86f 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -15,6 +15,7 @@ import type { } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { DocBadge } from "./slide-over-doc-badge"; type SlideOverInfoTabEditViewProps = { @@ -140,14 +141,7 @@ export function SlideOverInfoTabEditView({ tech={draft.tech} techPhone={draft.techPhone} vendors={vendors} - onSave={(patch) => - onDraftChange({ - vendorId: patch.vendorId, - company: patch.company, - tech: patch.tech, - techPhone: patch.techPhone, - }) - } + onSave={(patch) => onDraftChange(toVendorTablePatch(patch))} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index f50d464d..02ee2880 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -16,6 +16,7 @@ import { PMTypeCell } from "./cells/pm-type-cell"; import { StatusCell } from "./cells/status-cell"; import { TypeCell } from "./cells/type-cell"; import { VendorCell } from "./cells/vendor-cell"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { toInlineDraftRow } from "./to-inline-draft-row"; import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells"; @@ -193,14 +194,7 @@ export function WoTableInlineRowCells({ tech={draft.tech} techPhone={draft.techPhone} vendors={vendors} - onSave={(p) => - onPatch({ - vendorId: p.vendorId, - company: p.company, - tech: p.tech, - techPhone: p.techPhone, - }) - } + onSave={(p) => onPatch(toVendorTablePatch(p))} />
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 7c7c2d50..26773141 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -10,6 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell"; import { VendorCell } from "./cells/vendor-cell"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import type { WoTableRowHandlers } from "./wo-table-row"; type WoTableRowServiceCellsProps = { @@ -67,14 +68,7 @@ export function WoTableRowServiceCells({ techPhone={row.techPhone} vendors={vendors} q={search} - onSave={(p) => - onPatchRow({ - vendorId: p.vendorId, - company: p.company, - tech: p.tech, - techPhone: p.techPhone, - }) - } + onSave={(p) => onPatchRow(toVendorTablePatch(p))} /> { expect(onChangeStatus).toHaveBeenCalledWith("Completed"); }); - it("disables Completed when company is missing (SH-183)", () => { + it("disables Completed when company is missing", () => { const onChangeStatus = vi.fn(); renderWithProviders( @@ -141,7 +141,7 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).not.toHaveBeenCalled(); }); - it("allows Completed when vendorId is set even if company label is empty (SH-183)", () => { + it("allows Completed when vendorId is set even if company label is empty", () => { const onChangeStatus = vi.fn(); renderWithProviders( @@ -159,7 +159,7 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).toHaveBeenCalledWith("Completed"); }); - it("allows Completed when vendorId has an empty company label and a live dispatch status (SH-183)", () => { + it("allows Completed when vendorId has an empty company label and a live dispatch status", () => { const onChangeStatus = vi.fn(); renderWithProviders( @@ -178,7 +178,7 @@ describe("StatusCell pending uplift closability", () => { }); it.each(["Cancelled", "Canceled", "Refused"] as const)( - "disables Completed when vendorId is leftover from a %s dispatch (SH-183)", + "disables Completed when vendorId is leftover from a %s dispatch", (primaryDispatchStatus) => { const onChangeStatus = vi.fn(); @@ -206,7 +206,54 @@ describe("StatusCell pending uplift closability", () => { }, ); - it("allows Completed when technician is empty if company is set (SH-183)", () => { + it("disables Completed when the board omits vendorId after a Refused primary", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + + const completed = screen.getByRole("button", { name: /completed/i }); + expect(completed).toBeDisabled(); + expect(completed).toHaveAttribute("title", "Missing: Company"); + }); + + it("allows Completed after a vendor patch clears leftover refused status", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + }); + + it("allows Completed when technician is empty if company is set", () => { const onChangeStatus = vi.fn(); renderWithProviders( diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 99283be9..970eeff7 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -61,13 +61,27 @@ describe("mapWorkOrderTableRow primaryDispatchStatus", () => { expect(row.primaryDispatchStatus).toBe(value); }); - it("ignores undocumented dispatchStatus aliases", () => { + it("maps backend #74 omitted vendor with Refused primaryDispatchStatus", () => { + const row = mapWorkOrderTableRow({ + id: 9, + vendorId: null, + vendorName: null, + primaryDispatchStatus: "Refused", + }); + expect(row.vendorId).toBe(""); + expect(row.company).toBe(""); + expect(row.primaryDispatchStatus).toBe("Refused"); + }); + + it("maps live vendorId with empty company label and Sent status", () => { const row = mapWorkOrderTableRow({ id: 9, vendorId: 45, - dispatchStatus: "Refused", - vendorDispatchStatus: "Cancelled", + vendorName: "", + primaryDispatchStatus: "Sent", }); - expect(row.primaryDispatchStatus).toBe(""); + expect(row.vendorId).toBe("45"); + expect(row.company).toBe(""); + expect(row.primaryDispatchStatus).toBe("Sent"); }); }); diff --git a/src/test/domain/work-orders/utils/vendor-assignment-patch.test.ts b/src/test/domain/work-orders/utils/vendor-assignment-patch.test.ts new file mode 100644 index 00000000..72aaa7d2 --- /dev/null +++ b/src/test/domain/work-orders/utils/vendor-assignment-patch.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +describe("toVendorTablePatch", () => { + it("clears leftover primaryDispatchStatus so a new vendor is a live assignment", () => { + const patch = toVendorTablePatch({ + vendorId: "9", + company: "New Co", + tech: "Pat", + techPhone: "555", + }); + expect(patch.primaryDispatchStatus).toBe(""); + expect(patch.vendorId).toBe("9"); + + const input = tableRowToClosabilityInput({ + dispatcherId: "u1", + dispatcherName: "Alex", + company: patch.company ?? "", + vendorId: patch.vendorId ?? "", + tech: patch.tech ?? "", + completedDate: "2026-07-15", + woNumber: "20260623001", + pm: "HVAC PM", + docStatus: "Yes", + mediaCount: 1, + type: "PM", + primaryDispatchStatus: patch.primaryDispatchStatus, + } as WorkOrderTableRow); + expect(getClosabilityGaps(input)).not.toContain("Company"); + }); +}); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index c463dd5f..240ed796 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -77,7 +77,7 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); - it("allows company without technician (SH-183)", () => { + it("allows company without technician", () => { const gaps = getClosabilityGaps(makeInput({ tech: "" })); expect(gaps).toEqual([]); expect(gaps).not.toContain("Technician"); @@ -88,7 +88,7 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); }); - it("allows Completed when vendorId is set and company/technician are empty (SH-183)", () => { + it("allows Completed when vendorId is set and company/technician are empty", () => { expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]); }); @@ -99,7 +99,7 @@ describe("getClosabilityGaps", () => { }); it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)( - "blocks Completed without a company for type %s (SH-183)", + "blocks Completed without a company for type %s", (type) => { const gaps = getClosabilityGaps( makeInput({ @@ -309,7 +309,7 @@ describe("detailToClosabilityInput", () => { expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]); }); - it("treats vendorId without vendorName as assigned company (SH-183)", () => { + it("treats vendorId without vendorName as assigned company", () => { const detail = { assignedTo: "Alice", completedDate: "2026-07-15", @@ -330,7 +330,7 @@ describe("detailToClosabilityInput", () => { }); it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])( - "ignores inactive dispatch status %s when resolving assigned company (SH-183)", + "ignores inactive dispatch status %s when resolving assigned company", (status) => { const inactiveOnly = { assignedTo: "Alice", @@ -352,7 +352,7 @@ describe("detailToClosabilityInput", () => { ); it.each(["Verified", "Completed", "Sent"])( - "treats dispatch status %s as a live company assignment (SH-183)", + "treats dispatch status %s as a live company assignment", (status) => { const liveAssignment = { assignedTo: "Alice", @@ -374,7 +374,7 @@ describe("detailToClosabilityInput", () => { }, ); - it("uses the active dispatch after an inactive cancelled spelling (SH-183)", () => { + it("uses the active dispatch after an inactive cancelled spelling", () => { const supersededThenCurrent = { assignedTo: "Alice", completedDate: "2026-07-15", @@ -413,14 +413,14 @@ describe("tableRowToClosabilityInput", () => { type: "", } as WorkOrderTableRow; - it("keeps vendorId as a live assignment when primary dispatch status is absent (SH-183)", () => { + it("keeps vendorId as a live assignment when primary dispatch status is absent", () => { const input = tableRowToClosabilityInput(closableRow); expect(input.vendorId).toBe("45"); expect(getClosabilityGaps(input)).toEqual([]); }); it.each(["Cancelled", "Canceled", "Refused"] as const)( - "clears company assignment when primary dispatch status is %s (SH-183)", + "clears company assignment when primary dispatch status is %s", (primaryDispatchStatus) => { const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus }); expect(input.company).toBe(""); @@ -429,11 +429,26 @@ describe("tableRowToClosabilityInput", () => { }, ); - it("raises Company when the board omits vendor assignment (inactive primary, SH-183)", () => { - const input = tableRowToClosabilityInput({ ...closableRow, vendorId: "", company: "" }); + it("raises Company when the board omits vendor assignment (inactive primary)", () => { + const input = tableRowToClosabilityInput({ + ...closableRow, + vendorId: "", + company: "", + primaryDispatchStatus: "Refused", + }); expect(input.vendorId).toBe(""); expect(getClosabilityGaps(input)).toContain("Company"); }); + + it("keeps a live vendorId after a vendor patch clears leftover refused status", () => { + const input = tableRowToClosabilityInput({ + ...closableRow, + company: "New Co", + vendorId: "9", + primaryDispatchStatus: "", + }); + expect(getClosabilityGaps(input)).toEqual([]); + }); }); describe("readDetailWoType", () => { From 493104c56d42857972ab283cdad8a92afefe04eb Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 14:29:57 -0300 Subject: [PATCH 16/20] fix(work-orders): narrow vendor patch type for inline drafts Vendor assignment patch must not carry table-row status, which blocked tsc because Canceled is not a draft status. --- src/domain/work-orders/utils/vendor-assignment-patch.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/domain/work-orders/utils/vendor-assignment-patch.ts b/src/domain/work-orders/utils/vendor-assignment-patch.ts index ef6cf05c..0f39e146 100644 --- a/src/domain/work-orders/utils/vendor-assignment-patch.ts +++ b/src/domain/work-orders/utils/vendor-assignment-patch.ts @@ -1,4 +1,4 @@ -import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; export type VendorAssignmentFields = { vendorId: string; @@ -7,7 +7,12 @@ export type VendorAssignmentFields = { techPhone: string; }; -export function toVendorTablePatch(fields: VendorAssignmentFields): WorkOrderTablePatch { +export type VendorTablePatch = Pick< + WorkOrderTableRow, + "vendorId" | "company" | "tech" | "techPhone" | "primaryDispatchStatus" +>; + +export function toVendorTablePatch(fields: VendorAssignmentFields): VendorTablePatch { return { vendorId: fields.vendorId, company: fields.company, From 972e5913f3d9fc114c721164366d6f5c13fdae86 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 14:47:04 -0300 Subject: [PATCH 17/20] fix(work-orders): treat vendor status overlay as local-only on board patch Board PATCH persists vendorId only. primaryDispatchStatus is optimistic until the producer returns a live dispatch after vendor save. --- .../mappers/work-order-board-patch-mapper.ts | 1 + .../work-order-board-patch-mapper.test.ts | 16 +++++++++++++ .../work-orders/utils/wo-closability.test.ts | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index c3e55a58..87d68f25 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -42,6 +42,7 @@ const LOCAL_ONLY_KEYS = new Set([ "originalDate", /** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */ "isAddOn", + "primaryDispatchStatus", ]); export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index f26d13fe..8e728756 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -5,6 +5,7 @@ import { isLocalOnlyTablePatch, mapDocStatusToApi, } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; describe("mapDocStatusToApi", () => { it("maps NN to NotNeeded", () => { @@ -25,6 +26,10 @@ describe("isLocalOnlyTablePatch", () => { it("does not treat site as local-only", () => { expect(isLocalOnlyTablePatch({ site: "BK5" })).toBe(false); }); + + it("treats primaryDispatchStatus overlay as local-only", () => { + expect(isLocalOnlyTablePatch({ primaryDispatchStatus: "" })).toBe(true); + }); }); describe("expandTablePatchToBoardFieldOps", () => { @@ -136,6 +141,17 @@ describe("expandTablePatchToBoardFieldOps", () => { }), ).toEqual([{ field: "vendorId", value: "7" }]); + expect( + expandTablePatchToBoardFieldOps( + toVendorTablePatch({ + vendorId: "9", + company: "New Co", + tech: "Pat", + techPhone: "555", + }), + ), + ).toEqual([{ field: "vendorId", value: "9" }]); + expect( expandTablePatchToBoardFieldOps({ site: "BK5", diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 240ed796..9d97358d 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -449,6 +449,30 @@ describe("tableRowToClosabilityInput", () => { }); expect(getClosabilityGaps(input)).toEqual([]); }); + + it.each(["Pending", "Sent"] as const)( + "keeps company assignment after vendor save when primary dispatch status is %s", + (primaryDispatchStatus) => { + const input = tableRowToClosabilityInput({ + ...closableRow, + company: "New Co", + vendorId: "9", + primaryDispatchStatus, + }); + expect(input.vendorId).toBe("9"); + expect(getClosabilityGaps(input)).not.toContain("Company"); + }, + ); + + it("raises Company when leftover Refused remains on the patched vendor id", () => { + const input = tableRowToClosabilityInput({ + ...closableRow, + company: "New Co", + vendorId: "9", + primaryDispatchStatus: "Refused", + }); + expect(getClosabilityGaps(input)).toContain("Company"); + }); }); describe("readDetailWoType", () => { From d8da3a50483f38ee50b1d7d0e5ccdaa7d3c824b1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 15:09:35 -0300 Subject: [PATCH 18/20] test(work-orders): pin vendor PATCH response as live company assignment Round-trip: board PATCH emits vendorId only; a Pending response keeps Completed unblocked, while a leftover Refused response still raises Missing: Company. --- .../use-work-order-table-mutations.test.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 73729279..6b2b3a64 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -25,6 +25,11 @@ import { applySchedulePatch, useWorkOrderTableMutations, } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -345,3 +350,116 @@ describe("useWorkOrderTableMutations schedule isAddOn from BE", () => { ); }); }); + +describe("useWorkOrderTableMutations vendor assignment round-trip", () => { + let rowState: WorkOrderTableRow; + + beforeEach(() => { + getById.mockReset(); + update.mockReset(); + patchBoardField.mockReset(); + rowState = { + ...BASE_ROW, + vendorId: "1", + company: "", + tech: "", + completedDate: "2026-07-15", + docStatus: "Yes", + mediaCount: 1, + woNumber: "20260623001", + primaryDispatchStatus: "Refused", + primaryDispatchId: 10, + dispatchRowVersion: "disp-v1", + }; + }); + + it("keeps Company off the gap list when vendor PATCH returns a Pending assignment", async () => { + const returnedRow: WorkOrderTableRow = { + ...rowState, + vendorId: "9", + company: "New Co", + primaryDispatchStatus: "Pending", + primaryDispatchId: 11, + }; + patchBoardField.mockResolvedValue(returnedRow); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField( + 42, + toVendorTablePatch({ + vendorId: "9", + company: "New Co", + tech: "", + techPhone: "", + }), + ); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ primaryDispatchStatus: "Pending" }), + ), + ); + + expect(patchBoardField).toHaveBeenCalledWith( + 42, + expect.objectContaining({ field: "vendorId", value: "9" }), + ); + const payload = patchBoardField.mock.calls[0]?.[1] as Record; + expect(payload).not.toHaveProperty("primaryDispatchStatus"); + expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).not.toContain("Company"); + }); + + it("keeps a Company gap when vendor PATCH still returns Refused", async () => { + const returnedRow: WorkOrderTableRow = { + ...rowState, + vendorId: "9", + company: "New Co", + primaryDispatchStatus: "Refused", + }; + patchBoardField.mockResolvedValue(returnedRow); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField( + 42, + toVendorTablePatch({ + vendorId: "9", + company: "New Co", + tech: "", + techPhone: "", + }), + ); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ primaryDispatchStatus: "Refused" }), + ), + ); + expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).toContain("Company"); + }); +}); From 9a347a67fbcf7b5434d28c3b37458f7db75abdf9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 15:39:39 -0300 Subject: [PATCH 19/20] test(work-orders): pin vendor PATCH producer row onto closability --- .../work-order-table-row-mapper.test.ts | 14 ++++++ .../use-work-order-table-mutations.test.tsx | 50 +++++++++++++------ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 970eeff7..74bdc2bb 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -84,4 +84,18 @@ describe("mapWorkOrderTableRow primaryDispatchStatus", () => { expect(row.company).toBe(""); expect(row.primaryDispatchStatus).toBe("Sent"); }); + + it("maps vendor PATCH replacement dispatch as Pending with a new primaryDispatchId", () => { + const row = mapWorkOrderTableRow({ + id: 1, + VendorId: 5, + VendorName: "New Co", + PrimaryDispatchStatus: "Pending", + PrimaryDispatchId: 11, + }); + expect(row.vendorId).toBe("5"); + expect(row.company).toBe("New Co"); + expect(row.primaryDispatchStatus).toBe("Pending"); + expect(row.primaryDispatchId).toBe(11); + }); }); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 6b2b3a64..1f629b09 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -21,6 +21,7 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; import { applySchedulePatch, useWorkOrderTableMutations, @@ -374,13 +375,21 @@ describe("useWorkOrderTableMutations vendor assignment round-trip", () => { }); it("keeps Company off the gap list when vendor PATCH returns a Pending assignment", async () => { - const returnedRow: WorkOrderTableRow = { - ...rowState, - vendorId: "9", - company: "New Co", - primaryDispatchStatus: "Pending", - primaryDispatchId: 11, - }; + const returnedRow = mapWorkOrderTableRow({ + id: 42, + VendorId: 9, + VendorName: "New Co", + PrimaryDispatchStatus: "Pending", + PrimaryDispatchId: 11, + DispatcherId: "d1", + DispatcherName: "Dispatcher", + CompletedDate: "2026-07-15", + InternalWONumber: "20260623001", + PM: "HVAC", + DocStatus: "Yes", + MediaCount: 1, + WorkOrderType: "PM", + }); patchBoardField.mockResolvedValue(returnedRow); const onPatch = vi.fn(); @@ -409,26 +418,37 @@ describe("useWorkOrderTableMutations vendor assignment round-trip", () => { await waitFor(() => expect(onPatch).toHaveBeenCalledWith( 42, - expect.objectContaining({ primaryDispatchStatus: "Pending" }), + expect.objectContaining({ + primaryDispatchStatus: "Pending", + primaryDispatchId: 11, + vendorId: "9", + }), ), ); expect(patchBoardField).toHaveBeenCalledWith( 42, - expect.objectContaining({ field: "vendorId", value: "9" }), + expect.objectContaining({ + field: "vendorId", + value: "9", + dispatchVersion: "disp-v1", + primaryDispatchId: 10, + }), ); const payload = patchBoardField.mock.calls[0]?.[1] as Record; expect(payload).not.toHaveProperty("primaryDispatchStatus"); + expect(returnedRow.primaryDispatchId).not.toBe(10); expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).not.toContain("Company"); }); it("keeps a Company gap when vendor PATCH still returns Refused", async () => { - const returnedRow: WorkOrderTableRow = { - ...rowState, - vendorId: "9", - company: "New Co", - primaryDispatchStatus: "Refused", - }; + const returnedRow = mapWorkOrderTableRow({ + id: 42, + VendorId: 9, + VendorName: "New Co", + PrimaryDispatchStatus: "Refused", + PrimaryDispatchId: 10, + }); patchBoardField.mockResolvedValue(returnedRow); const onPatch = vi.fn(); From 8614f9415c1216e378be0b9d3f87d92bfaad7452 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 15:53:03 -0300 Subject: [PATCH 20/20] feat(work-orders): relocate dispatcher filter under the title (SH-242) Place dispatcher scoping under the Work Orders heading as a plain trigger so the week/day bar stays boxed, without changing board params. --- e2e/work-orders/week-day-navigation.spec.ts | 13 ++ .../list/filter-bar/dispatcher-filter.tsx | 35 +---- .../filter-bar/work-orders-filter-bar.tsx | 40 ++---- src/app/(protected)/workorders/index.tsx | 16 ++- src/components/ui/page-header.tsx | 5 +- .../workorders/dispatcher-filter.test.tsx | 125 ++++++++++++++++++ .../work-orders-dispatcher-layout.test.tsx | 112 ++++++++++++++++ 7 files changed, 282 insertions(+), 64 deletions(-) create mode 100644 src/test/app/(protected)/workorders/dispatcher-filter.test.tsx create mode 100644 src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx diff --git a/e2e/work-orders/week-day-navigation.spec.ts b/e2e/work-orders/week-day-navigation.spec.ts index 5ec5402b..8a8e1245 100644 --- a/e2e/work-orders/week-day-navigation.spec.ts +++ b/e2e/work-orders/week-day-navigation.spec.ts @@ -80,6 +80,19 @@ test.describe("Work Orders week/day navigation (SH-240)", () => { await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ timeout: 15_000, }); + const dispatcher = page.getByRole("button", { name: "All dispatchers" }); + await expect(dispatcher).toBeVisible(); + await expect( + page.getByLabel("Week and day").getByRole("button", { name: "All dispatchers" }), + ).toHaveCount(0); + const titleBox = await page.getByRole("heading", { name: "Work Orders" }).boundingBox(); + const dispatcherBox = await dispatcher.boundingBox(); + const weekBox = await page.getByRole("button", { name: "Week 8/17–8/21" }).boundingBox(); + expect(titleBox).toBeTruthy(); + expect(dispatcherBox).toBeTruthy(); + expect(weekBox).toBeTruthy(); + expect(dispatcherBox!.y).toBeGreaterThan(titleBox!.y); + expect(weekBox!.y).toBeGreaterThan(dispatcherBox!.y); await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible(); await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live"); diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx index 6bee3519..3998b53f 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx @@ -27,6 +27,7 @@ function userInitials(name: string): string { function DispatcherAvatar({ name, color }: { name: string; color: string }) { return ( @@ -75,8 +76,8 @@ export function DispatcherFilter({ return ( @@ -89,35 +90,13 @@ export function DispatcherFilter({ onAnchor={setAnchor} open={open} disabled={disabled} - className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors" - style={ - !allSelected - ? { - height: 38, - padding: "0 12px", - borderRadius: 6, - fontSize: 13, - fontWeight: 600, - background: "var(--color-primary-tint)", - borderColor: "#bfe2f5", - color: "var(--primary)", - } - : { - height: 38, - padding: "0 12px", - borderRadius: 6, - fontSize: 13, - fontWeight: 600, - background: "var(--card)", - borderColor: "var(--border)", - color: "var(--foreground)", - } - } + className="flex w-auto shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors" + style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }} aria-haspopup="listbox" > - + {label} - + void; selectedDayIso: string; onSelectedDayChange: (dayIso: string) => void; - dispatcherIds: Set; - onDispatcherChange: (ids: Set) => void; typeFilter: WorkOrderTypeFilter; onTypeChange: (value: WorkOrderTypeFilter) => void; - users: WorkOrderUser[]; - usersLoading?: boolean; - currentUserId?: string | number; filteredCount: number; scopeTotalCount: number; disabled?: boolean; @@ -28,13 +21,8 @@ export function WorkOrdersFilterBar({ onWeekChange, selectedDayIso, onSelectedDayChange, - dispatcherIds, - onDispatcherChange, typeFilter, onTypeChange, - users, - usersLoading = false, - currentUserId, filteredCount, scopeTotalCount, disabled = false, @@ -46,29 +34,21 @@ export function WorkOrdersFilterBar({ className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)" aria-disabled={disabled} > -
- +
+
+ + +
{resultLabel}
-
- - -
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 8d5b1739..5707a4ef 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -2,6 +2,7 @@ import { Box } from "@mui/material"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; +import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter"; import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels"; @@ -30,6 +31,16 @@ export default function WorkOrdersListPage() { surface eyebrow="OPERATIONS" title="Work Orders" + belowTitle={ + + } actions={ {subtitle} + {Boolean(belowTitle) &&
{belowTitle}
} {Boolean(actions) && ( { + it("renders a plain trigger without a boxed border", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const trigger = screen.getByRole("button", { name: "All dispatchers" }); + expect(trigger.className.split(/\s+/)).not.toContain("border"); + expect(trigger.style.height).not.toBe("38px"); + expect(trigger.style.borderColor).toBe(""); + expect(trigger.style.background).toBe(""); + expect(trigger.style.fontWeight).toBe("500"); + }); + + it("opens the menu from the keyboard", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + { withAuth: false }, + ); + + screen.getByRole("button", { name: "All dispatchers" }).focus(); + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "My WOs only" })).toBeInTheDocument(); + }); + + it("shows loading skeleton while dispatchers are fetching", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByLabelText("Loading dispatchers")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "All dispatchers" })).not.toBeInTheDocument(); + }); + + it("disables the trigger when requested", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled(); + }); + + it("labels My WOs, Unassigned, and a multi-select count", () => { + const { rerender } = renderWithProviders( + , + { withAuth: false }, + ); + expect(screen.getByRole("button", { name: "My WOs" })).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByRole("button", { name: "Unassigned" })).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByRole("button", { name: "2 selected" })).toBeInTheDocument(); + }); + + it("scopes All, My WOs, Unassigned, and a named dispatcher", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + await user.click(screen.getByRole("button", { name: "All dispatchers" })); + await user.click(screen.getByRole("button", { name: "My WOs only" })); + expect(onChange).toHaveBeenLastCalledWith(new Set(["u1"])); + + await user.click(screen.getByRole("button", { name: "Unassigned" })); + expect(onChange).toHaveBeenLastCalledWith(new Set([ASSIGNEE_FILTER_UNASSIGNED])); + + await user.click(screen.getByRole("button", { name: "Blair Cho" })); + expect(onChange).toHaveBeenLastCalledWith(new Set(["u2"])); + + const menu = screen.getByRole("presentation"); + await user.click(within(menu).getByRole("button", { name: "All dispatchers" })); + expect(onChange).toHaveBeenLastCalledWith(new Set()); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx new file mode 100644 index 00000000..2ab48cfe --- /dev/null +++ b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx @@ -0,0 +1,112 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import WorkOrdersListPage from "@/app/(protected)/workorders/index"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => vi.fn(), + }; +}); + +vi.mock("@/providers/auth-context", () => ({ + useAuthContext: () => ({ user: { id: "u1" } }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen: false, + setAdvOpen: vi.fn(), + advApplied: null, + advInitial: EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: vi.fn(), + clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, + weekMonday: "2026-07-20", + selectedDayIso: "2026-07-20", + setWeekMonday: vi.fn(), + setSelectedDayIso: vi.fn(), + navigateToDay: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + typeFilter: "All", + setTypeFilter: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ patchField: vi.fn() }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ + data: [{ id: "u1", name: "Dispatcher One" }], + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: [] }), + useWorkOrderVendors: () => ({ data: [] }), + useWorkOrderBoardDetail: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [] }), +})); + +vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({ + isWoTableMockEnabled: () => false, +})); + +vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({ + WoTable: () => null, +})); + +vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({ + MediaUploadModal: () => null, +})); + +describe("Work Orders dispatcher filter layout (SH-242)", () => { + it("places the dispatcher trigger under the title and above week/day controls", () => { + renderWithProviders(, { route: "/workorders", withAuth: false }); + + const title = screen.getByRole("heading", { name: "Work Orders" }); + const dispatcher = screen.getByRole("button", { name: "All dispatchers" }); + const weekAndDay = screen.getByLabelText("Week and day"); + const week = screen.getByRole("button", { name: "Week 7/20–7/24" }); + + expect(title.compareDocumentPosition(dispatcher) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(dispatcher.compareDocumentPosition(weekAndDay) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(weekAndDay.contains(dispatcher)).toBe(false); + expect(weekAndDay.contains(week)).toBe(true); + expect(screen.getByText("0 of 0")).toBeInTheDocument(); + }); +});