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..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); @@ -167,6 +183,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..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 @@ -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); }; @@ -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 9b2016e7..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 @@ -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, @@ -37,6 +38,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 = ( @@ -124,6 +127,68 @@ 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 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, + ...row, + dispatchRowVersion: row.dispatchRowVersion || detail.info.dispatchRowVersion, + mediaCount: detail.info.mediaCount, + }, + } + : detail, + ); +} + +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); + syncDetailRow(queryClient, id, row); +} + +async function runPatchAndSyncDetail( + 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") recordSavedRow(sync, id, result.row); + return result; + } 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; + } +} + export function useWorkOrderTableMutations( onPatchOrHandlers: ((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers, @@ -141,6 +206,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 }); @@ -152,15 +218,29 @@ 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, savedRows }, + id, + persistPatch, + baseRow + ? () => baseRow + : (rowId) => + getRow?.(rowId) ?? + readDetailRow(queryClient, rowId) ?? + savedRows.get(String(rowId)) ?? + fallbackRow, + ), ), onMutate: async ({ id, persistPatch }) => { onPatch(id, persistPatch); @@ -190,12 +270,10 @@ export function useWorkOrderTableMutations( }, }); - const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) => - mergeAutoSchedulePatch(getRow?.(id), patch); - const patchField: WorkOrderTablePatchFn = (id, patch, options) => { - const persistPatch = resolvePatch(id, patch); - void patchMutation.mutateAsync({ id, patch, persistPatch }).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), ); @@ -205,9 +283,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/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/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 new file mode 100644 index 00000000..715592b9 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx @@ -0,0 +1,268 @@ +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"; + +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), + }, +})); + +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 makeClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +function makeWrapper(client = makeClient()) { + 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("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("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 () => { + 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("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(); + 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 }, + ); + }); });