From 09636ef590bd34a1f8aae905e8ca7bea3ea06fb7 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 11:43:33 -0300 Subject: [PATCH] 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. --- .../_hooks/use-work-orders-list-page.ts | 13 +- .../use-work-order-table-mutations.ts | 18 +- .../utils/completion-doc-auto-complete.ts | 25 +++ ...rk-orders-list-page-completion-doc.test.ts | 162 ++++++++++++++++++ .../use-work-order-table-mutations.test.tsx | 29 ++++ .../completion-doc-auto-complete.test.ts | 67 ++++++++ 6 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 src/domain/work-orders/utils/completion-doc-auto-complete.ts create mode 100644 src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts create mode 100644 src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts 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 266d84e3..08de1f5e 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 @@ -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 { 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 6922c367..a02950d3 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,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 { diff --git a/src/domain/work-orders/utils/completion-doc-auto-complete.ts b/src/domain/work-orders/utils/completion-doc-auto-complete.ts new file mode 100644 index 00000000..2337c8bd --- /dev/null +++ b/src/domain/work-orders/utils/completion-doc-auto-complete.ts @@ -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; +} 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 new file mode 100644 index 00000000..60c004c7 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts @@ -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(), + 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 { + 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); + }); +}); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index d3e1095d..97f364b5 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -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> | 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", () => { diff --git a/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts b/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts new file mode 100644 index 00000000..ab12e39e --- /dev/null +++ b/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts @@ -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 { + 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); + }); +});