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.
This commit is contained in:
Alexandre Brandizzi 2026-10-01 23:05:14 -03:00
parent 84cfc90282
commit f9b5d3e0ea
9 changed files with 189 additions and 8 deletions

View file

@ -167,6 +167,7 @@ export function useSlideOverEditState({
return;
}
onSave(row.id, patch, {
fallbackRow: infoSource ?? row,
onSuccess: () => {
notifySlideOverSaveSuccess(patch);
setBaseline(activeDraft);

View file

@ -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 = {

View file

@ -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);
};

View file

@ -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),
);

View file

@ -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" });
}

View file

@ -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 }),
);
});
});

View file

@ -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();
});

View file

@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
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" }),
);
});
});

View file

@ -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 },
);
});
});