From f9b5d3e0ea2b9b9330c129908471fa92b9e069be Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 1 Oct 2026 23:05:14 -0300 Subject: [PATCH 1/6] fix(workorders): save from the detail panel after the order leaves the list The board patch read the work order version from the loaded list only. Once a save moved the order out of that list (e.g. to another week), the next save, Cancel or Complete from the open panel failed for lack of a version. The panel now passes its server row as the fallback patch base. --- .../detail/use-slide-over-edit-state.ts | 1 + .../detail/work-order-slide-over.tsx | 2 + .../_hooks/use-work-orders-list-page.ts | 4 +- .../use-work-order-table-mutations.ts | 7 +- .../utils/work-order-feedback-toasts.ts | 18 ++- ...e-slide-over-edit-state-completed.test.tsx | 31 +++++ ...work-orders-list-page-cancel-toast.test.ts | 4 +- ...rk-order-table-mutations-off-list.test.tsx | 108 ++++++++++++++++++ .../utils/work-order-feedback-toasts.test.ts | 22 ++++ 9 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index 7d5deead..28f71e2d 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -167,6 +167,7 @@ export function useSlideOverEditState({ return; } onSave(row.id, patch, { + fallbackRow: infoSource ?? row, onSuccess: () => { notifySlideOverSaveSuccess(patch); setBaseline(activeDraft); diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index b080915a..b134f6af 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -21,6 +21,8 @@ import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs"; export type SlideOverSaveOptions = { onSuccess?: () => void; onError?: (error: Error) => void; + /** The panel's server row, used when the loaded list no longer holds this work order. */ + fallbackRow?: WorkOrderTableRow; }; type WorkOrderSlideOverProps = { diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 87987653..be144b41 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -131,14 +131,14 @@ export function useWorkOrdersListPage() { const handleConfirmCancel = () => { if (!confirmCancel) return; - patchWorkOrderAsCanceled(patchField, confirmCancel); + patchWorkOrderAsCanceled(patchField, confirmCancel, confirmCancel); setConfirmCancel(null); handleCloseSlideOver(); }; const handleConfirmComplete = () => { if (!confirmComplete) return; - patchWorkOrderAsCompleted(patchField, confirmComplete.id); + patchWorkOrderAsCompleted(patchField, confirmComplete.id, confirmComplete); setConfirmComplete(null); }; 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 9b2016e7..d336b44d 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 @@ -37,6 +37,8 @@ export type { WorkOrderTablePatch }; export type WorkOrderTablePatchOptions = { onSuccess?: () => void; onError?: (error: Error) => void; + /** Patch base when the loaded list no longer holds the work order (e.g. moved to another week). */ + fallbackRow?: WorkOrderTableRow; }; export type WorkOrderTablePatchFn = ( @@ -194,8 +196,9 @@ export function useWorkOrderTableMutations( mergeAutoSchedulePatch(getRow?.(id), patch); const patchField: WorkOrderTablePatchFn = (id, patch, options) => { - const persistPatch = resolvePatch(id, patch); - void patchMutation.mutateAsync({ id, patch, persistPatch }).then( + const baseRow = getRow?.(id) ? undefined : options?.fallbackRow; + const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch); + void patchMutation.mutateAsync({ id, patch, persistPatch, baseRow }).then( () => options?.onSuccess?.(), (error: Error) => options?.onError?.(error), ); diff --git a/src/domain/work-orders/utils/work-order-feedback-toasts.ts b/src/domain/work-orders/utils/work-order-feedback-toasts.ts index 152688f5..7b8d97bc 100644 --- a/src/domain/work-orders/utils/work-order-feedback-toasts.ts +++ b/src/domain/work-orders/utils/work-order-feedback-toasts.ts @@ -1,5 +1,8 @@ import { toast } from "react-toastify"; -import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row"; +import type { + WorkOrderTablePatch, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; import type { VendorAssignmentFields } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; @@ -9,6 +12,7 @@ type PersistPatchFn = ( options?: { onSuccess?: () => void; onError?: (error: Error) => void; + fallbackRow?: WorkOrderTableRow; }, ) => void; @@ -57,17 +61,27 @@ export function notifySlideOverSaveSuccess(patch: WorkOrderTablePatch) { export function patchWorkOrderAsCanceled( patchField: PersistPatchFn, row: { id: string | number; woNumber: string }, + fallbackRow?: WorkOrderTableRow, ) { patchField( row.id, { status: "Canceled" }, { onSuccess: () => notifyWorkOrderCanceled(row.woNumber), + fallbackRow, }, ); } -export function patchWorkOrderAsCompleted(patchField: PersistPatchFn, id: string | number) { +export function patchWorkOrderAsCompleted( + patchField: PersistPatchFn, + id: string | number, + fallbackRow?: WorkOrderTableRow, +) { + if (fallbackRow) { + patchField(id, { status: "Completed" }, { fallbackRow }); + return; + } patchField(id, { status: "Completed" }); } diff --git a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx index b6882d4d..e0d16599 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx @@ -203,4 +203,35 @@ describe("useSlideOverEditState completed lock", () => { expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage()); }); + + it("sends the panel's server row so a save works after the work order left the list", () => { + const onSave = vi.fn(); + const opened = baseRow({ rowVersion: "opened" }); + const fresh = baseRow({ scheduledOn: "2026-07-22", rowVersion: "after-first-save" }); + + const { result } = renderHook(() => + useSlideOverEditState({ + row: opened, + editMode: true, + infoSource: fresh, + onSave, + onClose: vi.fn(), + closeDisabled: false, + setTab: vi.fn(), + }), + ); + + act(() => { + result.current.handleDraftChange({ dispatcherName: "Blair" }); + }); + act(() => { + result.current.commit(); + }); + + expect(onSave).toHaveBeenCalledWith( + 100, + expect.anything(), + expect.objectContaining({ fallbackRow: fresh }), + ); + }); }); diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts index f9b11d28..f02cc705 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts @@ -117,7 +117,7 @@ describe("useWorkOrdersListPage cancel toast", () => { expect(patchField).toHaveBeenCalledWith( 77, { status: "Canceled" }, - expect.objectContaining({ onSuccess: expect.any(Function) }), + expect.objectContaining({ onSuccess: expect.any(Function), fallbackRow: row }), ); expect(toast.warning).not.toHaveBeenCalled(); @@ -137,7 +137,7 @@ describe("useWorkOrdersListPage cancel toast", () => { result.current.handleConfirmComplete(); }); - expect(patchField).toHaveBeenCalledWith(77, { status: "Completed" }); + expect(patchField).toHaveBeenCalledWith(77, { status: "Completed" }, { fallbackRow: row }); expect(toast.success).not.toHaveBeenCalled(); expect(toast.warning).not.toHaveBeenCalled(); }); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx new file mode 100644 index 00000000..0116b0d8 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const patchBoardField = vi.fn(); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + patchBoardField: (...args: unknown[]) => patchBoardField(...args), + }, +})); + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +import { toast } from "react-toastify"; +import { + applySchedulePatch, + useWorkOrderTableMutations, +} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +const DETAIL_ROW = { + id: 9, + woNumber: "00000000009", + scheduledOn: "2026-10-07", + status: "Scheduled", + scheduleWeekOnly: false, + targetWeek: "", + rowVersion: "AAAAAAAAB9E=", + dispatchRowVersion: "", + primaryDispatchId: null, +} as WorkOrderTableRow; + +describe("useWorkOrderTableMutations for a work order outside the loaded list", () => { + beforeEach(() => { + vi.clearAllMocks(); + patchBoardField.mockImplementation(async () => ({ ...DETAIL_ROW, rowVersion: "next" })); + }); + + it("patches against the fallback row when the table no longer holds the work order", async () => { + const onSuccess = vi.fn(); + const onError = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: vi.fn(), + getRow: () => undefined, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(9, applySchedulePatch("2026-10-02", false), { + onSuccess, + onError, + fallbackRow: DETAIL_ROW, + }); + }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(onError).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + expect(patchBoardField).toHaveBeenCalledWith( + 9, + expect.objectContaining({ workOrderVersion: "AAAAAAAAB9E=" }), + ); + }); + + it("prefers the table row when it still holds the work order", async () => { + const tableRow = { ...DETAIL_ROW, rowVersion: "table-version" }; + const onSuccess = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: vi.fn(), + getRow: () => tableRow, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(9, applySchedulePatch("2026-10-02", false), { + onSuccess, + fallbackRow: DETAIL_ROW, + }); + }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(patchBoardField).toHaveBeenCalledWith( + 9, + expect.objectContaining({ workOrderVersion: "table-version" }), + ); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts index e9a5483f..afe2b452 100644 --- a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts +++ b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "react-toastify"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { applyAssignedToTableSave, @@ -185,4 +186,25 @@ describe("work-order-feedback-toasts", () => { expect(toast.success).not.toHaveBeenCalled(); expect(toast.warning).not.toHaveBeenCalled(); }); + + it("forwards the panel row to cancel and complete patches", () => { + const patchField = vi.fn(); + const row = { id: 42, woNumber: "20260819001", rowVersion: "v7" } as WorkOrderTableRow; + + patchWorkOrderAsCanceled(patchField, row, row); + patchWorkOrderAsCompleted(patchField, 42, row); + + expect(patchField).toHaveBeenNthCalledWith( + 1, + 42, + { status: "Canceled" }, + expect.objectContaining({ fallbackRow: row }), + ); + expect(patchField).toHaveBeenNthCalledWith( + 2, + 42, + { status: "Completed" }, + { fallbackRow: row }, + ); + }); }); From e6548ffa178ac85eb098d705317a572916c22db6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 1 Oct 2026 23:17:33 -0300 Subject: [PATCH 2/6] refactor(workorders): extract slide-over open-tab effect to meet function size cap --- .../detail/use-slide-over-edit-state.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index 28f71e2d..efdaefbf 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -77,6 +77,29 @@ function useSlideOverInitialization({ }, [row?.id, editMode]); } +// Re-apply the requested tab on every open, including a second notification for the work order +// already on screen (same row id) where the identity effect does not re-run. Tab-only so an +// in-flight edit draft is never discarded. +function useSlideOverOpenRequestTab({ + row, + editMode, + initialTab, + setTab, + openRequestKey, +}: { + row: WorkOrderTableRow | null; + editMode: boolean; + initialTab: SlideOverTab; + setTab: (tab: SlideOverTab) => void; + openRequestKey: number; +}) { + useEffect(() => { + if (!row) return; + setTab(editMode ? "info" : initialTab); + // eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open + }, [openRequestKey]); +} + export function useSlideOverEditState({ row, editMode, @@ -109,14 +132,7 @@ export function useSlideOverEditState({ setUnsavedIntent, }); - // Re-apply the requested tab on every open, including a second notification for the work order - // already on screen (same row id) where the identity effect above does not re-run. Tab-only so - // an in-flight edit draft is never discarded. - useEffect(() => { - if (!row) return; - setTab(editMode ? "info" : initialTab); - // eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open - }, [openRequestKey]); + useSlideOverOpenRequestTab({ row, editMode, initialTab, setTab, openRequestKey }); const lockStatus = infoSource?.status ?? row?.status; const isInfoLocked = isSlideOverInfoLocked(lockStatus); From 522fea4db7e1403d6068797c74239098f8166af4 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 1 Oct 2026 23:27:17 -0300 Subject: [PATCH 3/6] fix(workorders): carry each saved version into the panel before the next save A save from the detail panel left the cached detail on the pre-save version until its refetch settled, so a quick second save, Cancel WO or Complete could conflict. The saved row's version now updates the detail cache before the patch queue releases, and panel patches read their base at run time. --- .../use-work-order-table-mutations.ts | 66 +++++++++++++++++-- ...rk-order-table-mutations-off-list.test.tsx | 48 +++++++++++++- 2 files changed, 107 insertions(+), 7 deletions(-) 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 d336b44d..9d6dccd2 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 @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type QueryClient, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import { @@ -17,6 +17,7 @@ import { type BoardOp, } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper"; +import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderTablePatch, WorkOrderTableRow, @@ -126,6 +127,51 @@ export async function runWorkOrderTablePatch( return { kind: "board", row: boardRow }; } +function readDetailRow( + queryClient: QueryClient, + id: string | number, +): WorkOrderTableRow | undefined { + return queryClient.getQueryData(queryKeys.workOrders.boardDetail(id))?.info; +} + +/** Carry a saved row's version into the open panel's detail before the next queued patch runs. */ +function syncDetailRowVersion( + queryClient: QueryClient, + id: string | number, + row: WorkOrderTableRow, +) { + queryClient.setQueryData(queryKeys.workOrders.boardDetail(id), (detail) => + detail && row.rowVersion + ? { + ...detail, + info: { + ...detail.info, + rowVersion: row.rowVersion, + dispatchRowVersion: row.dispatchRowVersion || detail.info.dispatchRowVersion, + }, + } + : detail, + ); +} + +async function runPatchAndSyncDetail( + queryClient: QueryClient, + id: string | number, + patch: WorkOrderTablePatch, + readBase: (id: string | number) => WorkOrderTableRow | undefined, +): Promise { + try { + const result = await runWorkOrderTablePatch(id, patch, readBase); + if (result.kind === "board") syncDetailRowVersion(queryClient, id, result.row); + return result; + } catch (error) { + if (error instanceof BoardPatchPartialSaveError) { + syncDetailRowVersion(queryClient, id, error.partialRow); + } + throw error; + } +} + export function useWorkOrderTableMutations( onPatchOrHandlers: ((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers, @@ -154,15 +200,25 @@ export function useWorkOrderTableMutations( id, persistPatch, baseRow, + fallbackRow, }: { id: string | number; patch: WorkOrderTablePatch; persistPatch: WorkOrderTablePatch; /** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */ baseRow?: WorkOrderTableRow; + /** Used when neither the table nor the detail cache holds the work order. */ + fallbackRow?: WorkOrderTableRow; }) => enqueuePatch(id, () => - runWorkOrderTablePatch(id, persistPatch, baseRow ? () => baseRow : getRow), + runPatchAndSyncDetail( + queryClient, + id, + persistPatch, + baseRow + ? () => baseRow + : (rowId) => getRow?.(rowId) ?? readDetailRow(queryClient, rowId) ?? fallbackRow, + ), ), onMutate: async ({ id, persistPatch }) => { onPatch(id, persistPatch); @@ -196,9 +252,9 @@ export function useWorkOrderTableMutations( mergeAutoSchedulePatch(getRow?.(id), patch); const patchField: WorkOrderTablePatchFn = (id, patch, options) => { - const baseRow = getRow?.(id) ? undefined : options?.fallbackRow; - const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch); - void patchMutation.mutateAsync({ id, patch, persistPatch, baseRow }).then( + const fallbackRow = options?.fallbackRow; + const persistPatch = mergeAutoSchedulePatch(getRow?.(id) ?? fallbackRow, patch); + void patchMutation.mutateAsync({ id, patch, persistPatch, fallbackRow }).then( () => options?.onSuccess?.(), (error: Error) => options?.onError?.(error), ); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx index 0116b0d8..4e7ad698 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx @@ -2,7 +2,9 @@ import type { ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { queryKeys } from "@/infra/query-key/query-key"; const patchBoardField = vi.fn(); @@ -22,10 +24,13 @@ import { useWorkOrderTableMutations, } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; -function makeWrapper() { - const client = new QueryClient({ +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); +} + +function makeWrapper(client = makeClient()) { return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; @@ -79,6 +84,45 @@ describe("useWorkOrderTableMutations for a work order outside the loaded list", ); }); + it("runs a queued second save on the first save's version, not the stale panel row", async () => { + const client = makeClient(); + client.setQueryData(queryKeys.workOrders.boardDetail(9), { + info: DETAIL_ROW, + } as WorkOrderBoardDetail); + let version = 0; + patchBoardField.mockImplementation(async () => { + version += 1; + return { ...DETAIL_ROW, rowVersion: `saved-${version}` }; + }); + const onSecondSuccess = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: vi.fn(), + getRow: () => undefined, + }), + { wrapper: makeWrapper(client) }, + ); + + act(() => { + result.current.patchField(9, { dueDate: "2026-10-09" }, { fallbackRow: DETAIL_ROW }); + result.current.patchField( + 9, + { dueDate: "2026-10-16" }, + { fallbackRow: DETAIL_ROW, onSuccess: onSecondSuccess }, + ); + }); + + await waitFor(() => expect(onSecondSuccess).toHaveBeenCalled()); + expect(patchBoardField.mock.calls[0][1]).toMatchObject({ workOrderVersion: "AAAAAAAAB9E=" }); + expect(patchBoardField.mock.calls[1][1]).toMatchObject({ workOrderVersion: "saved-1" }); + expect( + client.getQueryData(queryKeys.workOrders.boardDetail(9))?.info + .rowVersion, + ).toBe("saved-2"); + }); + it("prefers the table row when it still holds the work order", async () => { const tableRow = { ...DETAIL_ROW, rowVersion: "table-version" }; const onSuccess = vi.fn(); From 32b4ea0efacd65448ca2d1536754a222fbed66e3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 2 Oct 2026 00:05:10 -0300 Subject: [PATCH 4/6] fix(workorders): chain saved versions when the panel detail never loaded Keep the last saved row per work order so a queued panel save does not depend on the board-detail cache, and pass the completion-doc row as a fallback so saving a completion document works after the order leaves the list. --- .../_hooks/use-work-orders-list-page.ts | 2 +- .../use-work-order-table-mutations.ts | 38 ++++++++++++++----- ...rk-orders-list-page-completion-doc.test.ts | 5 ++- ...rk-order-table-mutations-off-list.test.tsx | 30 +++++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index be144b41..9d4f19d5 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -157,7 +157,7 @@ export function useWorkOrdersListPage() { generatedAt: data.generatedAt, }; setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData })); - const saved = await patchFieldAsync(row.id, { docStatus: status }); + const saved = await patchFieldAsync(row.id, { docStatus: status }, undefined, row); if (saved.kind !== "board" || !shouldCompleteAfterCompletionDoc(status, saved.row)) { return; } 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 9d6dccd2..75d3313a 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 @@ -154,19 +154,34 @@ function syncDetailRowVersion( ); } +type SavedRowSync = { + queryClient: QueryClient; + /** Last server row each patch returned, for panels whose detail query never cached data. */ + savedRows: Map; +}; + +function recordSavedRow( + { queryClient, savedRows }: SavedRowSync, + id: string | number, + row: WorkOrderTableRow, +) { + savedRows.set(String(id), row); + syncDetailRowVersion(queryClient, id, row); +} + async function runPatchAndSyncDetail( - queryClient: QueryClient, + sync: SavedRowSync, id: string | number, patch: WorkOrderTablePatch, readBase: (id: string | number) => WorkOrderTableRow | undefined, ): Promise { try { const result = await runWorkOrderTablePatch(id, patch, readBase); - if (result.kind === "board") syncDetailRowVersion(queryClient, id, result.row); + if (result.kind === "board") recordSavedRow(sync, id, result.row); return result; } catch (error) { if (error instanceof BoardPatchPartialSaveError) { - syncDetailRowVersion(queryClient, id, error.partialRow); + recordSavedRow(sync, id, error.partialRow); } throw error; } @@ -189,6 +204,7 @@ export function useWorkOrderTableMutations( const { onPatch, clearPatch, getRow } = handlers; const queryClient = useQueryClient(); const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []); + const savedRows = useMemo(() => new Map(), []); const invalidate = () => { void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); @@ -212,12 +228,16 @@ export function useWorkOrderTableMutations( }) => enqueuePatch(id, () => runPatchAndSyncDetail( - queryClient, + { queryClient, savedRows }, id, persistPatch, baseRow ? () => baseRow - : (rowId) => getRow?.(rowId) ?? readDetailRow(queryClient, rowId) ?? fallbackRow, + : (rowId) => + getRow?.(rowId) ?? + readDetailRow(queryClient, rowId) ?? + savedRows.get(String(rowId)) ?? + fallbackRow, ), ), onMutate: async ({ id, persistPatch }) => { @@ -248,9 +268,6 @@ export function useWorkOrderTableMutations( }, }); - const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) => - mergeAutoSchedulePatch(getRow?.(id), patch); - const patchField: WorkOrderTablePatchFn = (id, patch, options) => { const fallbackRow = options?.fallbackRow; const persistPatch = mergeAutoSchedulePatch(getRow?.(id) ?? fallbackRow, patch); @@ -264,9 +281,10 @@ export function useWorkOrderTableMutations( id: string | number, patch: WorkOrderTablePatch, baseRow?: WorkOrderTableRow, + fallbackRow?: WorkOrderTableRow, ) => { - const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch); - return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow }); + const persistPatch = mergeAutoSchedulePatch(baseRow ?? getRow?.(id) ?? fallbackRow, patch); + return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow, fallbackRow }); }; return { diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts index 7e2de77c..db23aa9d 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts @@ -131,10 +131,11 @@ describe("useWorkOrdersListPage completion document (SH-341)", () => { row: { ...saved, status: "Completed" }, }); const { result } = renderHook(() => useWorkOrdersListPage()); + const docRow = closableRow(); - await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"); + await result.current.saveCompletionDoc(docRow, { notes: "", generatedAt: "now" }, "Yes"); - expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" }); + expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" }, undefined, docRow); expect(patchFieldAsync).toHaveBeenNthCalledWith(2, 77, { status: "Completed" }, saved); expect(patchField).not.toHaveBeenCalled(); expect(result.current.confirmComplete).toBeNull(); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx index 4e7ad698..8c3c8d55 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx @@ -123,6 +123,36 @@ describe("useWorkOrderTableMutations for a work order outside the loaded list", ).toBe("saved-2"); }); + it("chains the saved version when the panel's detail query never cached data", async () => { + let version = 0; + patchBoardField.mockImplementation(async () => { + version += 1; + return { ...DETAIL_ROW, rowVersion: `saved-${version}` }; + }); + const onSecondSuccess = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: vi.fn(), + getRow: () => undefined, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(9, { dueDate: "2026-10-09" }, { fallbackRow: DETAIL_ROW }); + result.current.patchField( + 9, + { dueDate: "2026-10-16" }, + { fallbackRow: DETAIL_ROW, onSuccess: onSecondSuccess }, + ); + }); + + await waitFor(() => expect(onSecondSuccess).toHaveBeenCalled()); + expect(patchBoardField.mock.calls[1][1]).toMatchObject({ workOrderVersion: "saved-1" }); + }); + it("prefers the table row when it still holds the work order", async () => { const tableRow = { ...DETAIL_ROW, rowVersion: "table-version" }; const onSuccess = vi.fn(); From 47cdca55c26644a8e3b7b7e9b4cb1098e5dcbbc5 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 2 Oct 2026 00:11:11 -0300 Subject: [PATCH 5/6] fix(workorders): sync the whole saved row into the panel detail Copying only the version left the cached detail with pre-save fields, so a queued second panel save could send them with a fresh version and silently revert what the first save wrote (e.g. POC name). --- .../use-work-order-table-mutations.ts | 15 ++++--- ...rk-order-table-mutations-off-list.test.tsx | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) 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 75d3313a..98fe8fd7 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 @@ -134,20 +134,19 @@ function readDetailRow( return queryClient.getQueryData(queryKeys.workOrders.boardDetail(id))?.info; } -/** Carry a saved row's version into the open panel's detail before the next queued patch runs. */ -function syncDetailRowVersion( - queryClient: QueryClient, - id: string | number, - row: WorkOrderTableRow, -) { +/** Carry a saved row into the open panel's detail before the next queued patch runs. */ +function syncDetailRow(queryClient: QueryClient, id: string | number, row: WorkOrderTableRow) { queryClient.setQueryData(queryKeys.workOrders.boardDetail(id), (detail) => detail && row.rowVersion ? { ...detail, + // The whole saved row, not just its version: a queued patch built from fresh + // version + stale fields would silently revert what the previous save wrote. info: { ...detail.info, - rowVersion: row.rowVersion, + ...row, dispatchRowVersion: row.dispatchRowVersion || detail.info.dispatchRowVersion, + mediaCount: detail.info.mediaCount, }, } : detail, @@ -166,7 +165,7 @@ function recordSavedRow( row: WorkOrderTableRow, ) { savedRows.set(String(id), row); - syncDetailRowVersion(queryClient, id, row); + syncDetailRow(queryClient, id, row); } async function runPatchAndSyncDetail( diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx index 8c3c8d55..d2a42d08 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx @@ -7,10 +7,12 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta import { queryKeys } from "@/infra/query-key/query-key"; const patchBoardField = vi.fn(); +const updatePoc = vi.fn(); vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ workOrdersApi: { patchBoardField: (...args: unknown[]) => patchBoardField(...args), + updatePoc: (...args: unknown[]) => updatePoc(...args), }, })); @@ -123,6 +125,46 @@ describe("useWorkOrderTableMutations for a work order outside the loaded list", ).toBe("saved-2"); }); + it("keeps the first save's fields when a queued second panel save patches the same group", async () => { + const client = makeClient(); + client.setQueryData(queryKeys.workOrders.boardDetail(9), { + info: { ...DETAIL_ROW, pocName: "Old POC", pocPhone: "111", pocNotes: "" }, + } as WorkOrderBoardDetail); + let version = 0; + updatePoc.mockImplementation( + async (_id: unknown, payload: { pocName: string; pocPhone: string; pocNotes: string }) => { + version += 1; + return { ...DETAIL_ROW, ...payload, rowVersion: `saved-${version}` }; + }, + ); + const onSecondSuccess = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: vi.fn(), + getRow: () => undefined, + }), + { wrapper: makeWrapper(client) }, + ); + + act(() => { + result.current.patchField(9, { pocName: "New POC" }, { fallbackRow: DETAIL_ROW }); + result.current.patchField( + 9, + { pocPhone: "555" }, + { fallbackRow: DETAIL_ROW, onSuccess: onSecondSuccess }, + ); + }); + + await waitFor(() => expect(onSecondSuccess).toHaveBeenCalled()); + expect(updatePoc.mock.calls[1][1]).toMatchObject({ + pocName: "New POC", + pocPhone: "555", + workOrderVersion: "saved-1", + }); + }); + it("chains the saved version when the panel's detail query never cached data", async () => { let version = 0; patchBoardField.mockImplementation(async () => { From 3e849f47de9efa7c700a59bc81c61dfd5fd350e9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 2 Oct 2026 00:18:43 -0300 Subject: [PATCH 6/6] fix(workorders): retry off-list saves on the conflict's current version --- .../use-work-order-table-mutations.ts | 3 ++ ...rk-order-table-mutations-off-list.test.tsx | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+) 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 98fe8fd7..b6a21a2a 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 @@ -181,6 +181,9 @@ async function runPatchAndSyncDetail( } catch (error) { if (error instanceof BoardPatchPartialSaveError) { recordSavedRow(sync, id, error.partialRow); + } else if (error instanceof BoardPatchConflictError && error.currentState) { + // A retry before the refetch lands must patch the server's current version. + recordSavedRow(sync, id, error.currentState); } throw error; } diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx index d2a42d08..715592b9 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BoardPatchConflictError } from "@/domain/work-orders/errors/board-patch-errors"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { queryKeys } from "@/infra/query-key/query-key"; @@ -195,6 +196,49 @@ describe("useWorkOrderTableMutations for a work order outside the loaded list", expect(patchBoardField.mock.calls[1][1]).toMatchObject({ workOrderVersion: "saved-1" }); }); + it("retries an off-list save on the conflict's current version, not the stale panel row", async () => { + const client = makeClient(); + client.setQueryData(queryKeys.workOrders.boardDetail(9), { + info: DETAIL_ROW, + } as WorkOrderBoardDetail); + patchBoardField.mockRejectedValueOnce( + new BoardPatchConflictError("Work order was modified.", { + ...DETAIL_ROW, + rowVersion: "server-current", + }), + ); + const onRetrySuccess = vi.fn(); + const onFirstError = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: vi.fn(), + getRow: () => undefined, + }), + { wrapper: makeWrapper(client) }, + ); + + act(() => { + result.current.patchField( + 9, + { dueDate: "2026-10-09" }, + { fallbackRow: DETAIL_ROW, onError: onFirstError }, + ); + }); + await waitFor(() => expect(onFirstError).toHaveBeenCalled()); + act(() => { + result.current.patchField( + 9, + { dueDate: "2026-10-09" }, + { fallbackRow: DETAIL_ROW, onSuccess: onRetrySuccess }, + ); + }); + + await waitFor(() => expect(onRetrySuccess).toHaveBeenCalled()); + expect(patchBoardField.mock.calls[1][1]).toMatchObject({ workOrderVersion: "server-current" }); + }); + it("prefers the table row when it still holds the work order", async () => { const tableRow = { ...DETAIL_ROW, rowVersion: "table-version" }; const onSuccess = vi.fn();