mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 10:23:11 +00:00
fix(work-orders): complete the work order when its completion document is generated (SH-341)
Generating or uploading the completion document now moves a closable work order to Completed without the manual confirmation, as SH-115 requires. The SH-114 closability gate is evaluated on the row the server returned after the document save; Save draft, Not required, Past Due, Completed and Canceled rows are never auto-completed. The follow-up status patch uses that row's rowVersion so it does not conflict with the document save.
This commit is contained in:
parent
fc3e7591dc
commit
09636ef590
6 changed files with 309 additions and 5 deletions
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
|
||||
import {
|
||||
patchWorkOrderAsCanceled,
|
||||
patchWorkOrderAsCompleted,
|
||||
|
|
@ -112,7 +113,17 @@ export function useWorkOrdersListPage() {
|
|||
generatedAt: data.generatedAt,
|
||||
};
|
||||
setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
|
||||
await patchFieldAsync(row.id, { docStatus: status });
|
||||
const saved = await patchFieldAsync(row.id, { docStatus: status });
|
||||
if (saved.kind !== "board" || !shouldCompleteAfterCompletionDoc(status, saved.row)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// SH-341: generation is the one automatic completion — no confirmation dialog.
|
||||
await patchFieldAsync(row.id, { status: "Completed" }, saved.row);
|
||||
} catch {
|
||||
// The document is saved; the table mutation already surfaced the status error and
|
||||
// refetched, so the WO stays in its prior status for a manual retry.
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -134,11 +134,17 @@ export function useWorkOrderTableMutations(
|
|||
mutationFn: ({
|
||||
id,
|
||||
persistPatch,
|
||||
baseRow,
|
||||
}: {
|
||||
id: string | number;
|
||||
patch: WorkOrderTablePatch;
|
||||
persistPatch: WorkOrderTablePatch;
|
||||
}) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
|
||||
/** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */
|
||||
baseRow?: WorkOrderTableRow;
|
||||
}) =>
|
||||
enqueuePatch(id, () =>
|
||||
runWorkOrderTablePatch(id, persistPatch, baseRow ? () => baseRow : getRow),
|
||||
),
|
||||
onMutate: async ({ id, persistPatch }) => {
|
||||
onPatch(id, persistPatch);
|
||||
},
|
||||
|
|
@ -182,9 +188,13 @@ export function useWorkOrderTableMutations(
|
|||
);
|
||||
};
|
||||
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
return patchMutation.mutateAsync({ id, patch, persistPatch });
|
||||
const patchFieldAsync = (
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
baseRow?: WorkOrderTableRow,
|
||||
) => {
|
||||
const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch);
|
||||
return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow });
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
25
src/domain/work-orders/utils/completion-doc-auto-complete.ts
Normal file
25
src/domain/work-orders/utils/completion-doc-auto-complete.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
getClosabilityGaps,
|
||||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
/**
|
||||
* SH-115 / SH-341: completion is manual with confirmation, except that generating the
|
||||
* completion document completes the WO — but only when the SH-114 closability gate passes
|
||||
* on the persisted row. Not-required (NN) and drafts never complete; Past Due rows must be
|
||||
* rescheduled before any status change, so they are left for the dispatcher.
|
||||
*/
|
||||
export function shouldCompleteAfterCompletionDoc(
|
||||
status: CompDocSaveStatus,
|
||||
row: WorkOrderTableRow,
|
||||
): boolean {
|
||||
if (status !== "Yes" || row.docStatus !== "Yes") {
|
||||
return false;
|
||||
}
|
||||
if (row.status === "Completed" || row.status === "Canceled" || row.isPastDue) {
|
||||
return false;
|
||||
}
|
||||
return getClosabilityGaps(tableRowToClosabilityInput(row)).length === 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const patchField = vi.hoisted(() => vi.fn());
|
||||
const patchFieldAsync = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: {
|
||||
warning: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||
useWorkOrdersListFilters: () => ({
|
||||
search: "",
|
||||
handleSearchChange: vi.fn(),
|
||||
advOpen: false,
|
||||
setAdvOpen: vi.fn(),
|
||||
advApplied: null,
|
||||
advInitial: EMPTY_ADVANCED_FILTERS,
|
||||
applyAdvancedFilters: vi.fn(),
|
||||
clearAdvancedFilters: vi.fn(),
|
||||
clearAdvancedFilterChip: vi.fn(),
|
||||
activeFilterCount: 0,
|
||||
weekMonday: "2026-07-20",
|
||||
selectedDayIso: "2026-07-20",
|
||||
setWeekMonday: vi.fn(),
|
||||
setSelectedDayIso: vi.fn(),
|
||||
navigateToDay: vi.fn(),
|
||||
dispatcherIds: new Set<string>(),
|
||||
setDispatcherIds: vi.fn(),
|
||||
types: new Set(),
|
||||
setTypes: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||
useWorkOrdersTableData: () => ({
|
||||
rows: [],
|
||||
totalCount: 0,
|
||||
scopeTotalCount: 0,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
setSelectedId: vi.fn(),
|
||||
patchRow: vi.fn(),
|
||||
clearRowPatch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||
useWorkOrderTableMutations: () => ({
|
||||
patchField,
|
||||
patchFieldAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
|
||||
useWorkOrderUsers: () => ({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderLocations: () => ({ data: [] }),
|
||||
useWorkOrderVendors: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||
useDropdownOptionsByCategory: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||
|
||||
function closableRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 77,
|
||||
woNumber: "24866",
|
||||
type: "PM",
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
company: "Vendor Co",
|
||||
vendorId: "7",
|
||||
tech: "Tech One",
|
||||
completedDate: "2026-09-15",
|
||||
pm: "HVAC",
|
||||
status: "In Progress",
|
||||
docStatus: "No",
|
||||
mediaCount: 0,
|
||||
isPastDue: false,
|
||||
rowVersion: "v1",
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
} as WorkOrderTableRow;
|
||||
}
|
||||
|
||||
describe("useWorkOrdersListPage completion document (SH-341)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("completes the WO without confirmation once Generate persists the document", async () => {
|
||||
const saved = closableRow({ docStatus: "Yes", rowVersion: "v2" });
|
||||
patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: saved });
|
||||
patchFieldAsync.mockResolvedValueOnce({
|
||||
kind: "board",
|
||||
row: { ...saved, status: "Completed" },
|
||||
});
|
||||
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||
|
||||
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes");
|
||||
|
||||
expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" });
|
||||
expect(patchFieldAsync).toHaveBeenNthCalledWith(2, 77, { status: "Completed" }, saved);
|
||||
expect(patchField).not.toHaveBeenCalled();
|
||||
expect(result.current.confirmComplete).toBeNull();
|
||||
});
|
||||
|
||||
it("does not complete on Save draft or Not required", async () => {
|
||||
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||
|
||||
patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "No" }) });
|
||||
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "No");
|
||||
patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "NN" }) });
|
||||
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "NN");
|
||||
|
||||
expect(patchFieldAsync).toHaveBeenCalledTimes(2);
|
||||
expect(patchFieldAsync).not.toHaveBeenCalledWith(
|
||||
77,
|
||||
{ status: "Completed" },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the closability gate: a missing service leaves the status unchanged", async () => {
|
||||
patchFieldAsync.mockResolvedValueOnce({
|
||||
kind: "board",
|
||||
row: closableRow({ docStatus: "Yes", pm: "" }),
|
||||
});
|
||||
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||
|
||||
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes");
|
||||
|
||||
expect(patchFieldAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the generated document when the automatic completion is rejected", async () => {
|
||||
patchFieldAsync.mockResolvedValueOnce({
|
||||
kind: "board",
|
||||
row: closableRow({ docStatus: "Yes" }),
|
||||
});
|
||||
patchFieldAsync.mockRejectedValueOnce(new Error("Work order was modified."));
|
||||
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||
|
||||
await expect(
|
||||
result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(patchFieldAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -224,6 +224,35 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
|||
expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
|
||||
expect(serverState.status).toBe(BASE_SERVER.status);
|
||||
});
|
||||
it("sends the follow-up status patch with the rowVersion of the just-saved row (SH-341)", async () => {
|
||||
const staleTableRow = { ...BASE_ROW };
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => staleTableRow,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
let saved: Awaited<ReturnType<typeof result.current.patchFieldAsync>> | undefined;
|
||||
await act(async () => {
|
||||
saved = await result.current.patchFieldAsync(42, { docStatus: "Yes" });
|
||||
});
|
||||
expect(saved?.kind).toBe("board");
|
||||
const savedRow = saved?.kind === "board" ? saved.row : undefined;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.patchFieldAsync(42, { status: "Completed" }, savedRow);
|
||||
});
|
||||
|
||||
expect(patchBoardField).toHaveBeenLastCalledWith(42, {
|
||||
field: "lifecycleStatus",
|
||||
value: "Completed",
|
||||
workOrderVersion: "AAAAAAA=-next",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeAutoSchedule on board patch", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
|
||||
|
||||
function closablePmRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 42,
|
||||
woNumber: "24866",
|
||||
type: "PM",
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
company: "Vendor Co",
|
||||
vendorId: "7",
|
||||
tech: "Tech One",
|
||||
completedDate: "2026-09-15",
|
||||
pm: "HVAC",
|
||||
status: "In Progress",
|
||||
docStatus: "Yes",
|
||||
mediaCount: 0,
|
||||
isPastDue: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
} as WorkOrderTableRow;
|
||||
}
|
||||
|
||||
describe("shouldCompleteAfterCompletionDoc (SH-341)", () => {
|
||||
it("completes a closable PM once the generated document is persisted", () => {
|
||||
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow())).toBe(true);
|
||||
});
|
||||
|
||||
it("never completes on Save draft or Not required", () => {
|
||||
expect(shouldCompleteAfterCompletionDoc("No", closablePmRow({ docStatus: "No" }))).toBe(false);
|
||||
expect(shouldCompleteAfterCompletionDoc("NN", closablePmRow({ docStatus: "NN" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not complete when the server did not persist the document as generated", () => {
|
||||
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ docStatus: "No" }))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["dispatcher", { dispatcherId: "", dispatcherName: "" }],
|
||||
["vendor", { company: "", vendorId: "" }],
|
||||
["completed date", { completedDate: "" }],
|
||||
["real WO number", { woNumber: "SH02137" }],
|
||||
["service", { pm: "" }],
|
||||
["pending uplift", { upliftSummary: { hasUplift: true, pendingCount: 1 } }],
|
||||
["photo on a Reactive WO", { type: "Reactive" as const, mediaCount: 0 }],
|
||||
])("keeps the SH-114 closability gate: missing %s blocks completion", (_label, overrides) => {
|
||||
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow(overrides))).toBe(false);
|
||||
});
|
||||
|
||||
it("completes a Reactive WO that has its completion photo", () => {
|
||||
expect(
|
||||
shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ type: "Reactive", mediaCount: 1 })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves Completed, Canceled, and Past Due rows unchanged", () => {
|
||||
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Completed" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Canceled" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ isPastDue: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue