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/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index b348fd21..d1263828 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -68,6 +68,7 @@ interface MockState { patchedBody?: Record; patchedCompanyId?: string; deletedId?: string; + deleteConfirmedOpenWorkOrders?: boolean; } async function fulfillJson(route: Route, body: unknown, status = 200) { @@ -307,8 +308,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); @@ -321,6 +323,8 @@ async function mockVendorApi( return; } if (route.request().method() === "DELETE") { + state.deleteConfirmedOpenWorkOrders = + requestUrl.searchParams.get("confirmOpenWorkOrders") === "true"; if (options.deleteConflict) { await fulfillJson( route, @@ -576,20 +580,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 }); @@ -597,13 +609,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(); 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/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)/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/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 96f5e3b7..82ae4333 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -93,6 +93,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; @@ -110,7 +111,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); @@ -223,6 +224,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-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/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index b1a24c3c..62d65f15 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -299,6 +299,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 ff63ef4c..11a7ea4b 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -1,4 +1,4 @@ -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"; @@ -14,8 +14,43 @@ import { 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(",") @@ -23,18 +58,59 @@ 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; tradeOptions: string[]; + 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, tradeOptions, + tradeOptionsLoading = false, }: VendorTradeSpecialtiesFieldProps) { const [tradeInput, setTradeInput] = useState(""); + const [feedbackMessage, setFeedbackMessage] = useState(null); + const knownTradesRef = useRef>(new Set()); + const tradeListUnavailable = !tradeOptionsLoading && tradeOptions.length === 0; + const gateActive = tradeOptions.length > 0 || tradeOptionsLoading; return ( @@ -47,10 +123,28 @@ export function VendorTradeSpecialtiesField({ name={`technicians.${index}.tradeSpecialties`} render={({ field }) => { 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 (gateActive && !canonical) { + setFeedbackMessage(resolveRejectionMessage(normalized, tradeOptionsLoading)); + 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 +158,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); @@ -147,6 +241,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. */} + ); } 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/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; types: Set; onTypesChange: (value: Set) => 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, types, onTypesChange, - users, - usersLoading = false, - currentUserId, filteredCount, scopeTotalCount, disabled = false, @@ -46,22 +34,8 @@ export function WorkOrdersFilterBar({ className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)" aria-disabled={disabled} > -
- -
- - {resultLabel} - -
@@ -74,6 +48,9 @@ export function WorkOrdersFilterBar({ />
+ + {resultLabel} +
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..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,20 +23,7 @@ type StatusCellProps = { }; function rowClosabilityGaps(wo: WorkOrderTableRow) { - return getClosabilityGaps({ - dispatcherId: wo.dispatcherId, - dispatcherName: wo.dispatcherName, - company: wo.company, - 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/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-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-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))} /> -
- {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 4706ce02..0d170a5d 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -12,6 +12,7 @@ import type { 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"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup"; import { useWoTableInlineCreate } from "./use-wo-table-inline-create"; @@ -69,6 +70,24 @@ function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShell ); } +function WoTableDayFooter({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) { + if (!tableData.showDayPagination) { + return undefined; + } + return ( + + ); +} + export function WoTable({ tableData, users, @@ -188,7 +207,7 @@ export function WoTable({ 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); + }, []); + + const goToPrevPage = useCallback(() => { + setPage((current) => Math.max(0, current - 1)); + }, []); + + const goToNextPage = useCallback(() => { + 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 f56409f9..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 @@ -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, @@ -51,7 +56,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advActive, isFetching, }); - const { displayRows } = local; + const { displayRows, ...localUi } = local; const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]); @@ -85,16 +90,27 @@ 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, + dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS, + onRevealed: local.triggerFlash, + }); + 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, unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError), }); const isEmptyWeek = @@ -127,30 +143,24 @@ 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, + revealScheduledRow: pagination.revealRow, }; } diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 0ee85a17..a48da809 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) && ( => { - 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/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/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 8dfe70e0..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 @@ -267,6 +267,10 @@ function readHasPendingUplift( return undefined; } +function readPrimaryDispatchStatus(item: Record): string { + return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus"); +} + export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const item = asRecord(raw); const scheduledOn = toDateInputValue( @@ -348,6 +352,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..8e691382 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,7 @@ export interface WorkOrderBoardDetailInfoApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; + primaryDispatchStatus?: 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..dfad8c12 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -184,6 +184,7 @@ export interface WorkOrderBoardRowApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; + primaryDispatchStatus?: 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..d07549d7 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,7 @@ export interface WorkOrderTableRow { extraServices: string[]; vendorId: string; company: string; + primaryDispatchStatus?: string; tech: string; techPhone: string; apptStart: string; @@ -138,6 +139,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..8caab266 --- /dev/null +++ b/src/domain/work-orders/utils/day-pagination.ts @@ -0,0 +1,80 @@ +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 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, + 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/domain/work-orders/utils/enrich-detail-closability-from-board.ts b/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts index 9d315378..f86caa0c 100644 --- a/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts +++ b/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts @@ -3,7 +3,7 @@ import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-deta /** * Overlay authoritative closability fields from board detail onto a legacy GetById detail. - * Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114/SH-183 sources. + * Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114 sources. */ export function enrichDetailClosabilityFromBoard( detail: WorkOrderDetail, diff --git a/src/domain/work-orders/utils/vendor-assignment-patch.ts b/src/domain/work-orders/utils/vendor-assignment-patch.ts new file mode 100644 index 00000000..0f39e146 --- /dev/null +++ b/src/domain/work-orders/utils/vendor-assignment-patch.ts @@ -0,0 +1,23 @@ +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +export type VendorAssignmentFields = { + vendorId: string; + company: string; + tech: string; + techPhone: string; +}; + +export type VendorTablePatch = Pick< + WorkOrderTableRow, + "vendorId" | "company" | "tech" | "techPhone" | "primaryDispatchStatus" +>; + +export function toVendorTablePatch(fields: VendorAssignmentFields): VendorTablePatch { + return { + vendorId: fields.vendorId, + company: fields.company, + tech: fields.tech, + techPhone: fields.techPhone, + primaryDispatchStatus: "", + }; +} diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 1290ecd3..841f68d2 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -1,16 +1,20 @@ +import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispatch"; 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"; -/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */ +/** Fields required to evaluate whether a WO may be marked Completed. */ export type WoClosabilityInput = { dispatcherId: string; dispatcherName: string; - /** Company / vendor assignment — required for Completed (SH-183). */ + /** Company / vendor assignment — required for Completed. */ 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 +61,8 @@ function hasDispatcher(row: Pick): boolean { + return Boolean((row.company ?? "").trim() || (row.vendorId ?? "").trim()); } function hasService(pm: string): boolean { @@ -80,7 +84,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 +147,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl dispatcherId: draft.assigneeId, dispatcherName: draft.assigneeName, company: draft.vendorName, + vendorId: draft.vendorId ?? "", tech: "", completedDate: "", woNumber: draft.woNumber, @@ -153,19 +158,55 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl }; } +const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]); + +export function isInactiveDispatchStatus(status: string | undefined): boolean { + return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase()); +} + +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, + }; +} + +function currentAssignedDispatch( + dispatches: WorkOrderDispatchSummary[], +): WorkOrderDispatchSummary | undefined { + return dispatches.find((dispatch) => { + if (isInactiveDispatchStatus(dispatch.status)) { + 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 vendorName = wo.dispatches.find((dispatch) => - Boolean(dispatch.vendorName.trim()), - )?.vendorName; + const assigned = currentAssignedDispatch(wo.dispatches); 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)/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..03990707 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx @@ -0,0 +1,192 @@ +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, + tradeOptionsLoading = false, + initialTradeSpecialties = "", + valuesRef, +}: { + tradeOptions: string[]; + tradeOptionsLoading?: boolean; + 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("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(); + + 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"); + }); +}); 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", }), ); diff --git a/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx b/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx new file mode 100644 index 00000000..d186543a --- /dev/null +++ b/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx @@ -0,0 +1,125 @@ +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter"; +import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; +import { renderWithProviders } from "@/test/test-utils"; + +const users = [ + { id: "u1", name: "Alex Rivera" }, + { id: "u2", name: "Blair Cho" }, +]; + +describe("DispatcherFilter (SH-242)", () => { + 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/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..e6e50cfe 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 @@ -120,12 +120,12 @@ describe("StatusCell pending uplift closability", () => { 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,119 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).not.toHaveBeenCalled(); }); - it("allows Completed when technician is empty if company is set (SH-183)", () => { + it("allows Completed when vendorId is set even if company label is empty", () => { + 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 vendorId has an empty company label and a live dispatch 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.each(["Cancelled", "Canceled", "Refused"] as const)( + "disables Completed when vendorId is leftover from a %s dispatch", + (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("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/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 new file mode 100644 index 00000000..01e98569 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx @@ -0,0 +1,75 @@ +import { act, renderHook } from "@testing-library/react"; +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", () => { + 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); + }); + + 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-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/app/(protected)/workorders/wo-table-search-empty.test.tsx b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx index 410f0c8d..db7e5685 100644 --- a/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx @@ -43,6 +43,7 @@ function tableData( selectedRow: null, flashId: null, triggerFlash: vi.fn(), + revealScheduledRow: vi.fn(), dragViz: null, setDragViz: vi.fn(), dragState: { current: { dragId: null, overId: null } }, @@ -56,6 +57,15 @@ function tableData( 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; } @@ -79,7 +89,7 @@ function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) { ); } -describe("WoTable search empty vs Unassigned queue (SH-165/SH-240)", () => { +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()); 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..1724e2d9 --- /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(), + types: new Set(), + setTypes: 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(); + }); +}); 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/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 93123601..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 @@ -50,3 +50,52 @@ describe("mapWorkOrderTableRow isAddOn", () => { expect(row.isAddOn).toBe(false); }); }); + +describe("mapWorkOrderTableRow primaryDispatchStatus", () => { + it.each([ + ["primaryDispatchStatus", "Refused"], + ["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("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, + vendorName: "", + primaryDispatchStatus: "Sent", + }); + expect(row.vendorId).toBe("45"); + 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 73729279..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,10 +21,16 @@ 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, } 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 +351,135 @@ 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 = 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(); + 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", + primaryDispatchId: 11, + vendorId: "9", + }), + ), + ); + + expect(patchBoardField).toHaveBeenCalledWith( + 42, + 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 = mapWorkOrderTableRow({ + id: 42, + VendorId: 9, + VendorName: "New Co", + PrimaryDispatchStatus: "Refused", + PrimaryDispatchId: 10, + }); + 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"); + }); +}); 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..0a71ba3c --- /dev/null +++ b/src/test/domain/work-orders/utils/day-pagination.test.ts @@ -0,0 +1,84 @@ +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"; + +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("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/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 c900ccec..9d97358d 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"; @@ -21,6 +23,7 @@ function makeInput(overrides: Partial = {}): WoClosabilityIn dispatcherId: "u1", dispatcherName: "Alex", company: "Acme HVAC", + vendorId: "", tech: "Sam", completedDate: "2026-07-01", woNumber: "1001", @@ -74,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"); @@ -85,6 +88,32 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); }); + it("allows Completed when vendorId is set and company/technician are empty", () => { + 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.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)( + "blocks Completed without a company for type %s", + (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( @@ -188,6 +217,7 @@ describe("wizardDraftToClosabilityInput", () => { dispatcherId: "u1", dispatcherName: "Alice", company: "ACME", + vendorId: "", tech: "", completedDate: "", woNumber: "20260623001", @@ -221,6 +251,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 +308,171 @@ describe("detailToClosabilityInput", () => { expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]); }); + + it("treats vendorId without vendorName as assigned company", () => { + 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([]); + }); + + it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])( + "ignores inactive dispatch status %s when resolving assigned company", + (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.each(["Verified", "Completed", "Sent"])( + "treats dispatch status %s as a live company assignment", + (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", () => { + const supersededThenCurrent = { + 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: 1, status: "Canceled" }, + { 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("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", () => { + 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", + (primaryDispatchStatus) => { + const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus }); + expect(input.company).toBe(""); + expect(input.vendorId).toBe(""); + expect(getClosabilityGaps(input)).toContain("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([]); + }); + + 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", () => {