From 8ae54a61733b82678d087515baca8ecade919d28 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 10:08:44 -0300 Subject: [PATCH] fix(work-orders): use overdue=true and harden board patch overlays --- .../_hooks/use-work-orders-table-data.ts | 22 +++++++++ src/app/(protected)/workorders/index.tsx | 9 ++-- .../work-orders/types/work-order-board.ts | 12 ++--- .../use-work-order-table-mutations.ts | 49 +++++++++++++++++-- .../work-orders/utils/board-query-params.ts | 28 +++++++---- .../utils/board-query-params.test.ts | 28 ++++++++--- 6 files changed, 118 insertions(+), 30 deletions(-) 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 f26e5e8a..233bd027 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 @@ -196,6 +196,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { [displayRows, selectedId], ); + const clearRowPatch = useCallback((id: string | number) => { + const key = String(id); + setPatches((prev) => { + if (!(key in prev)) { + return prev; + } + const next = { ...prev }; + delete next[key]; + return next; + }); + }, []); + + const wasFetchingRef = useRef(false); + useEffect(() => { + if (wasFetchingRef.current && !isFetching) { + // Prune optimistic overlays once the invalidated board refetch lands. + setPatches({}); + } + wasFetchingRef.current = isFetching; + }, [isFetching]); + const patchRow = useCallback( (id: string | number, patch: Partial) => { const key = String(id); @@ -356,6 +377,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { setDragViz, dragState, patchRow, + clearRowPatch, reorderRows, endDrag, startDayDraft, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 4dc8a92a..022a6d8d 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -31,10 +31,11 @@ export default function WorkOrdersListPage() { const [mediaFiles, setMediaFiles] = useState>({}); const filters = useWorkOrdersListFilters(); const tableData = useWorkOrdersTableData(filters); - const { patchField } = useWorkOrderTableMutations( - tableData.patchRow, - (id) => tableData.rows.find((row) => String(row.id) === String(id)), - ); + const { patchField } = useWorkOrderTableMutations({ + onPatch: tableData.patchRow, + clearPatch: tableData.clearRowPatch, + getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)), + }); const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers(); const { data: locations = [] } = useWorkOrderLocations(); diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 2c6fe6a4..021fe1c0 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -192,13 +192,7 @@ export interface PagedResultApi { export type WorkOrderBoardSearchResponseApi = PagedResultApi; export type BoardDatePresetApi = - | "ThisWeek" - | "LastWeek" - | "ThisMonth" - | "Last3Months" - | "NextWeek" - | "NextMonth" - | "Custom"; + "ThisWeek" | "LastWeek" | "ThisMonth" | "Last3Months" | "NextWeek" | "NextMonth" | "Custom"; export const DATE_RANGE_TO_PRESET: Record = { "this-week": "ThisWeek", @@ -219,6 +213,8 @@ export interface WorkOrderBoardQueryParams { dispatchers?: string[]; myWorkOrders?: boolean; types?: number[]; + /** Past-due filter; independent of WorkOrderType (Other remains types=99). */ + overdue?: boolean; search?: string; } @@ -231,6 +227,8 @@ export interface WorkOrderBoardSearchQueryParams { dateTo?: string; sites?: string[]; types?: number[]; + /** Past-due filter; independent of WorkOrderType (Other remains types=99). */ + overdue?: boolean; dispatchers?: string[]; statuses?: number[]; pmTypes?: string[]; diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index 9427c412..e0026c86 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -22,10 +22,27 @@ import { queryKeys } from "@/infra/query-key/query-key"; export type { WorkOrderTablePatch }; +export type WorkOrderTablePatchHandlers = { + onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; + clearPatch: (id: string | number) => void; + getRow?: (id: string | number) => WorkOrderTableRow | undefined; +}; + export function useWorkOrderTableMutations( - onPatch: (id: string | number, patch: WorkOrderTablePatch) => void, - getRow?: (id: string | number) => WorkOrderTableRow | undefined, + onPatchOrHandlers: + ((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers, + getRowArg?: (id: string | number) => WorkOrderTableRow | undefined, ) { + const handlers: WorkOrderTablePatchHandlers = + typeof onPatchOrHandlers === "function" + ? { + onPatch: onPatchOrHandlers, + clearPatch: () => undefined, + getRow: getRowArg, + } + : onPatchOrHandlers; + + const { onPatch, clearPatch, getRow } = handlers; const queryClient = useQueryClient(); const invalidate = () => { @@ -52,8 +69,24 @@ export function useWorkOrderTableMutations( } let latest = current; + let completedOps = 0; for (const op of ops) { - latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest)); + try { + latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest)); + completedOps += 1; + } catch (error) { + if (completedOps > 0) { + const field = op.field; + const partial = new Error( + `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest; + throw partial; + } + throw error; + } } return { kind: "board" as const, row: latest }; @@ -68,12 +101,22 @@ export function useWorkOrderTableMutations( invalidate(); }, onError: (error: Error, { id }) => { + const partialRow = (error as Error & { partialRow?: WorkOrderTableRow }).partialRow; + if (partialRow) { + onPatch(id, partialRow); + toast.error(error.message); + invalidate(); + return; + } + if (error instanceof BoardPatchConflictError && error.currentState) { onPatch(id, error.currentState); toast.error(error.message || "Conflict — refreshed with latest data"); } else if (error instanceof BoardPatchValidationError) { + clearPatch(id); toast.error(error.message); } else { + clearPatch(id); toast.error(error.message || "Failed to update work order"); } invalidate(); diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index e920c95f..6f4824fe 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -6,7 +6,6 @@ import { DATE_RANGE_TO_PRESET, DOC_STATUS_API, LIFECYCLE_STATUS_API, - WORK_ORDER_TYPE_API, type WorkOrderBoardQueryParams, type WorkOrderBoardSearchQueryParams, } from "@/domain/work-orders/types/work-order-board"; @@ -17,9 +16,6 @@ import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-ord export const BOARD_SEARCH_MAX_PAGE_SIZE = 100; -/** Backend `types=99` filters by `isPastDue`, not WorkOrderType.Other. */ -export const OVERDUE_TYPE_API = WORK_ORDER_TYPE_API.Other; - function toBoardDispatcherId(id: string): string { if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) { return BOARD_DISPATCHER_UNASSIGNED; @@ -32,14 +28,19 @@ function mapDispatchers(ids: Iterable): string[] | undefined { return list.length > 0 ? list : undefined; } -function mapWoTypes(types: Iterable): number[] | undefined { +/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */ +function mapWoTypeFilters(types: Iterable): { + types?: number[]; + overdue?: boolean; +} { const list: number[] = []; + let overdue = false; for (const type of types) { if (type === "All") { continue; } if (type === "Overdue") { - list.push(OVERDUE_TYPE_API); + overdue = true; continue; } const api = mapWizardTypeToApi(type as WOType); @@ -47,7 +48,10 @@ function mapWoTypes(types: Iterable): number[] | undefined { list.push(api); } } - return list.length > 0 ? list : undefined; + return { + types: list.length > 0 ? list : undefined, + overdue: overdue || undefined, + }; } function mapStatuses(statuses: Iterable): number[] | undefined { @@ -79,13 +83,14 @@ export function toBoardQueryParams( search: string, ): WorkOrderBoardQueryParams { const [, weekTo] = weekRange(bar.weekMonday); - const types = bar.type !== "All" ? mapWoTypes([bar.type]) : undefined; + const typeFilters = bar.type !== "All" ? mapWoTypeFilters([bar.type]) : {}; return { weekStart: bar.weekMonday, weekEnd: weekTo, dispatchers: mapDispatchers(bar.dispatcherIds), - types, + types: typeFilters.types, + overdue: typeFilters.overdue, search: search.trim().length >= 2 ? search.trim() : undefined, }; } @@ -118,6 +123,8 @@ export function toBoardSearchQueryParams(input: { } } + const typeFilters = mapWoTypeFilters(filters.types); + const params: WorkOrderBoardSearchQueryParams = { page: input.page ?? 0, pageSize, @@ -125,7 +132,8 @@ export function toBoardSearchQueryParams(input: { input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined, datePreset, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, - types: mapWoTypes(filters.types), + types: typeFilters.types, + overdue: typeFilters.overdue, dispatchers: mapDispatchers(filters.dispatchers), statuses: mapStatuses(filters.statuses), pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined, diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 38bbe4d6..3fd01ec9 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -5,7 +5,6 @@ import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board import { BOARD_SEARCH_MAX_PAGE_SIZE, buildBoardSearchParamsRecord, - OVERDUE_TYPE_API, toBoardQueryParams, toBoardSearchQueryParams, } from "@/domain/work-orders/utils/board-query-params"; @@ -43,17 +42,19 @@ describe("toBoardQueryParams", () => { it("maps PM type filter to API types", () => { const params = toBoardQueryParams({ ...baseBar, type: "PM" }, ""); expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue bar filter to types=99", () => { + it("maps Overdue bar filter to overdue=true without types", () => { const params = toBoardQueryParams({ ...baseBar, type: "Overdue" }, ""); - expect(params.types).toEqual([OVERDUE_TYPE_API]); - expect(OVERDUE_TYPE_API).toBe(99); + expect(params.overdue).toBe(true); + expect(params.types).toBeUndefined(); }); it("omits types when filter is All", () => { const params = toBoardQueryParams(baseBar, ""); expect(params.types).toBeUndefined(); + expect(params.overdue).toBeUndefined(); }); }); @@ -99,14 +100,26 @@ describe("toBoardSearchQueryParams", () => { expect(thisWeek.dateTo).toBeUndefined(); }); - it("maps Overdue in advanced types to 99", () => { + it("maps Overdue-only advanced filter to overdue=true without types", () => { + const params = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + types: new Set(["Overdue"]), + }, + }); + expect(params.overdue).toBe(true); + expect(params.types).toBeUndefined(); + }); + + it("maps Overdue with real types as OR (types + overdue)", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, types: new Set(["PM", "Overdue"]), }, }); - expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, OVERDUE_TYPE_API]); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); + expect(params.overdue).toBe(true); }); it("resolves vendor company names to vendorIds", () => { @@ -128,6 +141,7 @@ describe("toBoardSearchQueryParams", () => { expect(params.search).toBeUndefined(); expect(params.sites).toBeUndefined(); expect(params.types).toBeUndefined(); + expect(params.overdue).toBeUndefined(); expect(params.dispatchers).toBeUndefined(); expect(params.statuses).toBeUndefined(); expect(params.pmTypes).toBeUndefined(); @@ -149,10 +163,12 @@ describe("buildBoardSearchParamsRecord", () => { const record = buildBoardSearchParamsRecord({ weekStart: "2026-07-13", types: [2, 6], + overdue: true, dispatchers: ["guid-a", "__unassigned__"], search: "", }); expect(record.types).toEqual([2, 6]); + expect(record.overdue).toBe(true); expect(record.dispatchers).toEqual(["guid-a", "__unassigned__"]); expect(record.search).toBeUndefined(); expect(Object.keys(record)).not.toContain("types[]");