diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx index e45070b9..1aeeade8 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -4,7 +4,6 @@ import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdro import { Text } from "@/components/ui/text"; import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import { CompDocDialogFooter } from "./comp-doc-dialog-footer"; import { CompDocDialogHeader } from "./comp-doc-dialog-header"; import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links"; @@ -18,7 +17,6 @@ type CompDocDialogProps = { onOpenChange: (open: boolean) => void; data?: CompDocData; onSave: (data: CompDocData, status: CompDocSaveStatus) => void; - onStatusChange?: (status: WizardWOStatus) => void; onDateCompletedChange?: (date: string) => void; }; @@ -28,7 +26,6 @@ export function CompDocDialog({ onOpenChange, data, onSave, - onStatusChange, onDateCompletedChange, }: CompDocDialogProps) { const dialog = useCompDocDialog({ @@ -36,7 +33,6 @@ export function CompDocDialog({ open, data, onSave, - onStatusChange, onDateCompletedChange, }); const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false); diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx index c1d99852..bc504e9a 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx @@ -8,7 +8,6 @@ import type { DocState, } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site"; @@ -25,7 +24,6 @@ type UseCompDocDialogArgs = { open: boolean; data?: CompDocData; onSave: (data: CompDocData, status: CompDocSaveStatus) => void; - onStatusChange?: (status: WizardWOStatus) => void; onDateCompletedChange?: (date: string) => void; }; @@ -61,7 +59,6 @@ export function useCompDocDialog({ open, data, onSave, - onStatusChange, onDateCompletedChange, }: UseCompDocDialogArgs) { const locId = String(row.locationId || ""); @@ -90,12 +87,12 @@ export function useCompDocDialog({ setNotes(data?.notes ?? ""); setDocState(docStateOf(completion?.docStatus || info.docStatus)); setGeneratedAt(data?.generatedAt ?? ""); - setEditDateCompleted(info.scheduledOn || ""); + setEditDateCompleted(info.completedDate || ""); }, [ open, info.id, info.docStatus, - info.scheduledOn, + info.completedDate, completion?.docStatus, data?.notes, data?.generatedAt, @@ -122,7 +119,7 @@ export function useCompDocDialog({ }; const syncDateIfChanged = () => { - if (editDateCompleted !== info.scheduledOn) { + if (editDateCompleted !== (info.completedDate || "")) { onDateCompletedChange?.(editDateCompleted); } }; @@ -149,7 +146,6 @@ export function useCompDocDialog({ setDocState("generated"); persist({ generatedAt: ts }, "Yes"); syncDateIfChanged(); - onStatusChange?.("Completed"); } const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({ diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 7f0d9a4f..e2d9fb58 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -148,8 +148,9 @@ export default function WorkOrdersListPage() { }} data={page.completionDocs[String(activeDocRow.id)]} onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)} - onStatusChange={(status) => page.patchField(activeDocRow.id, { status })} - onDateCompletedChange={(date) => page.patchField(activeDocRow.id, { scheduledOn: date })} + onDateCompletedChange={(date) => + page.patchField(activeDocRow.id, { completedDate: date }) + } /> )} diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index a12162c2..af886e9b 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -237,6 +237,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { dispatcherId, dispatcherName, scheduledOn, + completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")), targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")), dayGroup, dayLabel, diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4bcb4b60..ca435096 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -12,6 +12,8 @@ export interface WorkOrderTableRow { dispatcherId: string; dispatcherName: string; scheduledOn: string; + /** Work completed date (API CompletedDate); distinct from scheduledOn. */ + completedDate: string; targetWeek: string; dayGroup: string; dayLabel: string; 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 b254aad1..a861bf83 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 @@ -11,7 +11,10 @@ import { expandTablePatchToBoardFieldOps, isLocalOnlyTablePatch, } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; -import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper"; +import { + mapWorkOrderDetailToFormValues, + mapWorkOrderTableRow, +} from "@/domain/work-orders/mappers/work-order-mapper"; import type { WorkOrderTablePatch, WorkOrderTableRow, @@ -28,6 +31,96 @@ export type WorkOrderTablePatchHandlers = { getRow?: (id: string | number) => WorkOrderTableRow | undefined; }; +type PatchResult = + { kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow }; + +async function persistCompletedDate(id: string | number, completedDate: string): Promise { + const detail = await workOrdersApi.getById(id); + const values = mapWorkOrderDetailToFormValues(detail); + await workOrdersApi.update(id, { ...values, completedDate }); +} + +async function applyBoardFieldOps( + id: string | number, + patch: WorkOrderTablePatch, + getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined, +): Promise { + const ops = expandTablePatchToBoardFieldOps(patch); + if (ops.length === 0) { + throw new BoardPatchValidationError( + "NoPersistableFields", + "These changes cannot be saved on the board. Refresh and edit a supported field.", + ); + } + + const current = getRow?.(id); + if (!current?.rowVersion) { + throw new Error("Missing work order version for board patch. Refresh and retry."); + } + + let latest = current; + let completedOps = 0; + for (const op of ops) { + try { + latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest)); + completedOps += 1; + } catch (error) { + if (completedOps > 0) { + const field = op.field; + const partial = new Error( + `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest; + throw partial; + } + throw error; + } + } + + return latest; +} + +/** CompDoc completedDate uses EditWorkorder; board fields use PATCH …/board. */ +async function runWorkOrderTablePatch( + id: string | number, + patch: WorkOrderTablePatch, + getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined, +): Promise { + if (isLocalOnlyTablePatch(patch)) { + return { kind: "local", patch }; + } + + const completedDate = patch.completedDate; + const boardPatch: WorkOrderTablePatch = { ...patch }; + delete boardPatch.completedDate; + + let boardRow: WorkOrderTableRow | undefined; + const hasBoardKeys = Object.keys(boardPatch).length > 0; + if (hasBoardKeys && !isLocalOnlyTablePatch(boardPatch)) { + boardRow = await applyBoardFieldOps(id, boardPatch, getRow); + } else if (!hasBoardKeys && completedDate === undefined) { + throw new BoardPatchValidationError( + "NoPersistableFields", + "These changes cannot be saved on the board. Refresh and edit a supported field.", + ); + } + + if (completedDate !== undefined) { + await persistCompletedDate(id, completedDate); + } + + if (boardRow) { + return { + kind: "board", + row: completedDate !== undefined ? { ...boardRow, completedDate } : boardRow, + }; + } + + return { kind: "local", patch }; +} + export function useWorkOrderTableMutations( onPatchOrHandlers: ((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers, @@ -50,47 +143,8 @@ export function useWorkOrderTableMutations( }; const patchMutation = useMutation({ - mutationFn: async ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => { - if (isLocalOnlyTablePatch(patch)) { - return { kind: "local" as const, patch }; - } - - const ops = expandTablePatchToBoardFieldOps(patch); - if (ops.length === 0) { - throw new BoardPatchValidationError( - "NoPersistableFields", - "These changes cannot be saved on the board. Refresh and edit a supported field.", - ); - } - - const current = getRow?.(id); - if (!current?.rowVersion) { - throw new Error("Missing work order version for board patch. Refresh and retry."); - } - - let latest = current; - let completedOps = 0; - for (const op of ops) { - try { - latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest)); - completedOps += 1; - } catch (error) { - if (completedOps > 0) { - const field = op.field; - const partial = new Error( - `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${ - error instanceof Error ? error.message : "Unknown error" - }`, - ); - (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest; - throw partial; - } - throw error; - } - } - - return { kind: "board" as const, row: latest }; - }, + mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => + runWorkOrderTablePatch(id, patch, getRow), onMutate: async ({ id, patch }) => { onPatch(id, patch); }, diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx new file mode 100644 index 00000000..e58cbdc4 --- /dev/null +++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ + useLocationDetail: () => ({ data: undefined }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ + useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("react-toastify", () => ({ + toast: vi.fn(), +})); + +const SCHEDULED_ON = "2026-06-01"; + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Dispatcher", + scheduledOn: SCHEDULED_ON, + completedDate: "", + targetWeek: "", + dayGroup: "", + dayLabel: "", + location: "Seattle", + locationId: "mock-loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "", + company: "Vendor Co", + tech: "Tech One", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "Amazon Rep", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "PM — SEA1", + priority: "Sev 3", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + ...overrides, + }; +} + +describe("CompDocDialog Save Draft / Generate completion contract", () => { + const onSave = vi.fn(); + const onDateCompletedChange = vi.fn(); + + beforeEach(() => { + onSave.mockReset(); + onDateCompletedChange.mockReset(); + }); + + function renderDialog(row: WorkOrderTableRow) { + // Mirrors board wiring: only completedDate / docStatus — never scheduledOn or lifecycle. + const patches: Array> = []; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + render( + + { + onSave(data, status); + patches.push({ docStatus: status }); + }} + onDateCompletedChange={(date) => { + onDateCompletedChange(date); + patches.push({ completedDate: date }); + }} + /> + , + ); + + return { patches }; + } + + function changeDateCompleted(value: string) { + const dateInput = document.querySelector('input[type="date"]'); + expect(dateInput).not.toBeNull(); + fireEvent.change(dateInput!, { target: { value } }); + } + + it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", () => { + const row = baseRow(); + const { patches } = renderDialog(row); + + changeDateCompleted("2026-07-15"); + fireEvent.click(screen.getByRole("button", { name: /save draft/i })); + + expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15"); + expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No"); + expect(patches).toEqual([{ docStatus: "No" }, { completedDate: "2026-07-15" }]); + expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false); + expect(patches.some((patch) => patch.status === "Completed")).toBe(false); + expect(row.scheduledOn).toBe(SCHEDULED_ON); + expect(row.status).toBe("Scheduled"); + }); + + it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", () => { + const row = baseRow({ completedDate: "2026-07-01" }); + const { patches } = renderDialog(row); + + changeDateCompleted("2026-07-20"); + fireEvent.click(screen.getByRole("button", { name: /^generate$/i })); + + expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20"); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ generatedAt: expect.any(String) }), + "Yes", + ); + expect(patches).toEqual([{ docStatus: "Yes" }, { completedDate: "2026-07-20" }]); + expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false); + expect(patches.some((patch) => patch.status === "Completed")).toBe(false); + expect(row.scheduledOn).toBe(SCHEDULED_ON); + expect(row.status).toBe("Scheduled"); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx index 9e45b80e..0511dead 100644 --- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -55,6 +55,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow dispatcherId: "u1", dispatcherName: "Dispatcher", scheduledOn: "2026-07-20", + completedDate: "", targetWeek: "", dayGroup: "07/20", dayLabel: "Mon", diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index fbff8520..3b96668f 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -75,6 +75,7 @@ const canceledRow: WorkOrderTableRow = { dispatcherId: "u1", dispatcherName: "Test Dispatcher", scheduledOn: "2026-07-20", + completedDate: "", targetWeek: "2026-07-20", dayGroup: "2026-07-20", dayLabel: "Mon", diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index 25788b12..30f342b3 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -15,6 +15,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow dispatcherId: "10", dispatcherName: "Alex", scheduledOn: "2026-07-16", + completedDate: "", targetWeek: "", dayGroup: "2026-07-16", dayLabel: "Thu", diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts index 0f3dcce8..8543f18c 100644 --- a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts @@ -37,6 +37,7 @@ function row(overrides: Partial = {}): WorkOrderTableRow { dispatcherId: "10", dispatcherName: "Alex", scheduledOn: "2026-07-16", + completedDate: "", targetWeek: "", dayGroup: "2026-07-16", dayLabel: "Thu", diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 4667ee78..088e3bdb 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -19,6 +19,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow dispatcherId: "u1", dispatcherName: "Alice", scheduledOn: "2026-06-23", + completedDate: "", targetWeek: "", dayGroup: "06/23", dayLabel: "TUE · 06/23",