From f9b5d3e0ea2b9b9330c129908471fa92b9e069be Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 1 Oct 2026 23:05:14 -0300 Subject: [PATCH] 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 }, + ); + }); });