From 188499155ede283f36e957090a519145ead0e19b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 29 Jul 2026 09:23:34 -0300 Subject: [PATCH] fix(work-orders): persist docStatus via board PATCH with NotNeeded mapping Map API NotNeeded to UI NN on read. Emit board PATCH Yes|No|NotNeeded on write, and keep DocStatus off the EditWorkorder payload. --- src/api/api-paths.ts | 2 + src/app/(protected)/workorders/index.tsx | 8 +-- src/domain/work-orders/api/work-orders-api.ts | 64 ++++++++++++++++++- .../work-orders/mappers/work-order-mapper.ts | 22 ++++++- .../types/work-order-board-patch.ts | 8 +++ .../work-orders/types/work-order-detail.ts | 2 +- .../use-work-order-table-mutations.ts | 23 ++++++- .../mappers/work-order-form-payload.test.ts | 6 +- .../mappers/work-order-mapper.test.ts | 14 ++++ .../use-work-order-table-mutations.test.tsx | 63 +++++++++++++----- 10 files changed, 183 insertions(+), 29 deletions(-) create mode 100644 src/domain/work-orders/types/work-order-board-patch.ts diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 9b2f25eb..88b16228 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -26,6 +26,8 @@ export const API_PATHS = { addDispatchComment: "WorkOrder/AddDispatchComment", dispatchToVendor: "WorkOrder/DispatchToVendor", changeStatus: "WorkOrder/ChangeStatus", + boardDetail: (id: string | number) => `workorders/${id}/detail`, + boardPatch: (id: string | number) => `workorders/${id}/board`, }, pmSchedule: { getList: "PMSchedule/GetList", diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index ac6eee5a..c0c99ba9 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -149,11 +149,9 @@ export default function WorkOrdersListPage() { if (!open) setDocRow(null); }} data={completionDocs[String(activeDocRow.id)]} - // docStatus and completedDate persist server-side through the real work-order - // update API; patchField serializes same-id saves so a status save and a - // date save never race each other. There is no notes/content persistence - // endpoint on this branch, so `data` (notes/generatedAt) stays client-local - // for the preview only. + // docStatus persists via board PATCH (Yes|No|NotNeeded); completedDate via + // EditWorkorder. patchField serializes same-id saves so they never race. + // Notes/generatedAt stay client-local (no content endpoint on this branch). onSave={(data, status) => { setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data })); patchField(activeDocRow.id, { docStatus: status }); diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index a3ede840..88c26f45 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { api, apiDelete, apiGet, apiPost, apiPut, mapHttpStatusToMessage } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { isWoTableMockEnabled, @@ -49,6 +49,21 @@ import { type WorkOrdersListParams, type WorkOrdersListResult, } from "@/domain/work-orders/types/work-order-list"; +import type { WorkOrderBoardPatchPayload } from "@/domain/work-orders/types/work-order-board-patch"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readVersionField(record: Record): string { + for (const key of ["rowVersion", "RowVersion", "workOrderVersion", "WorkOrderVersion"]) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return ""; +} function buildSearchParams(params: WorkOrdersListParams): Record { const searchParams: Record = { @@ -175,6 +190,53 @@ export const workOrdersApi = { return mapWorkOrderDetail(handleApiResponse(data)); }, + /** + * Reads concurrency token from board detail for PATCH …/board. + * CompDoc docStatus persists on that contract, which requires workOrderVersion. + */ + resolveBoardWorkOrderVersion: async (id: string | number): Promise => { + if (shouldUseMockWorkOrderDetail(id) || isWoTableMockEnabled()) { + return `mock-version-${id}`; + } + const data = await apiGet(API_PATHS.workOrder.boardDetail(id)); + const root = asRecord(handleApiResponse(data) ?? data); + const info = asRecord(root.info ?? root.Info ?? root); + const version = readVersionField(info) || readVersionField(root); + if (!version) { + throw new Error("Missing work order version for board patch. Refresh and retry."); + } + return version; + }, + + /** Persist a single board-editable field (e.g. docStatus Yes|No|NotNeeded). */ + patchBoardField: async ( + id: string | number, + payload: WorkOrderBoardPatchPayload, + ): Promise => { + if (shouldUseMockWorkOrderDetail(id) || isWoTableMockEnabled()) { + await mockNetworkDelay(); + return; + } + + const response = await api.patch(API_PATHS.workOrder.boardPatch(id), { + json: payload, + throwHttpErrors: false, + }); + + let data: unknown; + try { + data = await response.json(); + } catch { + data = undefined; + } + + if (response.ok) { + return; + } + + throw new Error(mapHttpStatusToMessage(response.status, data)); + }, + update: async ( id: string | number, values: WorkOrderFormSchemaValues | WorkOrderFormValues, diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index 432dbca7..eb7ea1e2 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -118,11 +118,29 @@ function readWoType(record: Record): WOType | "" { return ""; } +/** UI uses `NN`; board/API enum uses `NotNeeded` (also numeric 3). */ +export function mapDocStatusToApi(value: string): string { + if (value === "NN") { + return "NotNeeded"; + } + return value; +} + function readDocStatus(record: Record): CompletionDocStatus { + const rawValue = + record.docStatus ?? record.DocStatus ?? record.completionDoc ?? record.CompletionDoc; + if (typeof rawValue === "number") { + if (rawValue === 1) return "Yes"; + if (rawValue === 2) return "No"; + if (rawValue === 3) return "NN"; + } const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc"); if (raw === "Yes" || raw === "No" || raw === "NN") { return raw; } + if (raw === "NotNeeded") { + return "NN"; + } return ""; } @@ -348,7 +366,7 @@ export function mapWorkOrderFormToCreatePayload( DueDate: values.dueDate || null, ScheduledDate: values.scheduledDate || null, CompletedDate: values.completedDate || null, - DocStatus: values.docStatus || null, + // DocStatus is a board field — persist via PATCH …/board, not EditWorkorder. AssignTo: values.assignedTo || null, Description: values.description, PO: values.po || null, @@ -383,7 +401,7 @@ export function mapWorkOrderFormToUpdatePayload( VendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null, ScheduledDate: values.scheduledDate || null, CompletedDate: values.completedDate || null, - DocStatus: values.docStatus || null, + // DocStatus is a board field — persist via PATCH …/board, not EditWorkorder. CreatedDate: values.createdDate || null, Source: values.source || null, }; diff --git a/src/domain/work-orders/types/work-order-board-patch.ts b/src/domain/work-orders/types/work-order-board-patch.ts new file mode 100644 index 00000000..29f0edd2 --- /dev/null +++ b/src/domain/work-orders/types/work-order-board-patch.ts @@ -0,0 +1,8 @@ +/** Payload for PATCH /workorders/{id}/board (one field per request). */ +export interface WorkOrderBoardPatchPayload { + field: string; + value?: string | null; + workOrderVersion: string; + dispatchVersion?: string | null; + primaryDispatchId?: number | null; +} diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts index 0012de0f..3c15595d 100644 --- a/src/domain/work-orders/types/work-order-detail.ts +++ b/src/domain/work-orders/types/work-order-detail.ts @@ -45,7 +45,7 @@ export interface WorkOrderDetail { vendorNTE: string; scheduledDate: string; completedDate: string; - /** Completion-document status (API DocStatus/CompletionDoc); persisted via full update. */ + /** Completion-document status (API DocStatus); persisted via board PATCH. */ docStatus: CompletionDocStatus; source: string; comments: WorkOrderComment[]; 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 440c7d59..5ecbdd4b 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 @@ -7,6 +7,7 @@ import { mapWizardStatusToApi, } from "@/domain/work-orders/mappers/wizard-draft-mapper"; import { + mapDocStatusToApi, mapWorkOrderDetailToFormValues, mapWorkOrderTableRow, } from "@/domain/work-orders/mappers/work-order-mapper"; @@ -44,7 +45,6 @@ function buildFullUpdateValues( dueDate: patch.dueDate ?? values.dueDate, scheduledDate: patch.scheduledOn ?? values.scheduledDate, completedDate: patch.completedDate ?? values.completedDate, - docStatus: patch.docStatus ?? values.docStatus, assignedTo: patch.dispatcherName ?? values.assignedTo, problem: patch.pm ?? values.problem, source: patch.type ?? values.source, @@ -65,11 +65,32 @@ function buildFullUpdateValues( }; } +async function persistDocStatus( + id: string | number, + docStatus: NonNullable, +) { + const workOrderVersion = await workOrdersApi.resolveBoardWorkOrderVersion(id); + await workOrdersApi.patchBoardField(id, { + field: "docStatus", + value: mapDocStatusToApi(docStatus), + workOrderVersion, + }); +} + async function runWorkOrderPatch( id: string | number, patch: WorkOrderTablePatch, useFullUpdate: boolean | undefined, ): Promise { + if (patch.docStatus !== undefined) { + await persistDocStatus(id, patch.docStatus); + const rest: WorkOrderTablePatch = { ...patch }; + delete rest.docStatus; + if (Object.keys(rest).length === 0) { + return patch; + } + return runWorkOrderPatch(id, rest, useFullUpdate); + } if (patch.status !== undefined && !useFullUpdate) { const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus); await workOrdersApi.changeStatus(id, apiStatus); diff --git a/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts b/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts index 5e2d6c5a..418dcea5 100644 --- a/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts @@ -65,17 +65,17 @@ describe("mapWorkOrderFormToUpdatePayload", () => { expect(payload.LocationId).toBeNull(); }); - it("persists the completion-document status alongside the completed date", () => { + it("keeps completedDate on EditWorkorder and omits DocStatus (board field)", () => { const values = { ...createDefaultWorkOrderFormValues(), createdDate: "2026-01-15", completedDate: "2026-07-20", - docStatus: "Yes" as const, + docStatus: "NN" as const, }; const payload = mapWorkOrderFormToUpdatePayload(1, values); expect(payload.CompletedDate).toBe("2026-07-20"); - expect(payload.DocStatus).toBe("Yes"); + expect(payload).not.toHaveProperty("DocStatus"); }); }); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index b65f3e78..9803535e 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { mapUpliftRequest, + mapDocStatusToApi, mapWorkOrderDetail, mapWorkOrderDetailToFormValues, mapWorkOrderDispatchSummary, @@ -39,12 +40,25 @@ describe("mapWorkOrderDetail completion-document status", () => { expect(formValues.completedDate).toBe("2026-07-20"); }); + it("maps API NotNeeded to UI NN", () => { + expect(mapWorkOrderDetail({ Id: 1, DocStatus: "NotNeeded" }).docStatus).toBe("NN"); + expect(mapWorkOrderDetail({ Id: 2, docStatus: 3 }).docStatus).toBe("NN"); + }); + it("falls back to an empty doc status for unknown/missing values", () => { const detail = mapWorkOrderDetail({ Id: 1 }); expect(detail.docStatus).toBe(""); }); }); +describe("mapDocStatusToApi", () => { + it("maps NN to NotNeeded and keeps Yes/No", () => { + expect(mapDocStatusToApi("NN")).toBe("NotNeeded"); + expect(mapDocStatusToApi("Yes")).toBe("Yes"); + expect(mapDocStatusToApi("No")).toBe("No"); + }); +}); + describe("mapUpliftRequest", () => { it("maps decided uplift fields from PascalCase payload", () => { const result = mapUpliftRequest({ 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 f0750f80..8f4117b8 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 @@ -8,6 +8,8 @@ const getById = vi.fn(); const update = vi.fn(); const changeStatus = vi.fn(); const changeAssignment = vi.fn(); +const resolveBoardWorkOrderVersion = vi.fn(); +const patchBoardField = vi.fn(); vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ workOrdersApi: { @@ -15,6 +17,8 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ update: (...args: unknown[]) => update(...args), changeStatus: (...args: unknown[]) => changeStatus(...args), changeAssignment: (...args: unknown[]) => changeAssignment(...args), + resolveBoardWorkOrderVersion: (...args: unknown[]) => resolveBoardWorkOrderVersion(...args), + patchBoardField: (...args: unknown[]) => patchBoardField(...args), }, })); @@ -68,14 +72,39 @@ const BASE_DETAIL: WorkOrderDetail = { describe("useWorkOrderTableMutations completion-doc persistence", () => { let serverState: WorkOrderDetail; let events: string[]; + let lastBoardPatch: { field: string; value?: string | null; workOrderVersion: string } | null; beforeEach(() => { getById.mockReset(); update.mockReset(); changeStatus.mockReset(); changeAssignment.mockReset(); + resolveBoardWorkOrderVersion.mockReset(); + patchBoardField.mockReset(); serverState = { ...BASE_DETAIL }; events = []; + lastBoardPatch = null; + + resolveBoardWorkOrderVersion.mockImplementation(async () => { + events.push("resolveVersion:start"); + await delay(10); + events.push("resolveVersion:end"); + return "AAAAAAA="; + }); + + patchBoardField.mockImplementation(async (_id: unknown, payload: typeof lastBoardPatch) => { + events.push("boardPatch:start"); + await delay(5); + lastBoardPatch = payload; + if (payload?.field === "docStatus" && typeof payload.value === "string") { + serverState = { + ...serverState, + docStatus: + payload.value === "NotNeeded" ? "NN" : (payload.value as WorkOrderDetail["docStatus"]), + }; + } + events.push("boardPatch:end"); + }); getById.mockImplementation(async () => { events.push("getById:start"); @@ -91,7 +120,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { ...serverState, scheduledDate: values.scheduledDate, completedDate: values.completedDate, - docStatus: values.docStatus ?? serverState.docStatus, status: values.status, }; events.push("update:end"); @@ -99,23 +127,28 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { }); }); - it("maps docStatus into the real full-update payload instead of dropping it", async () => { + it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => { const onPatch = vi.fn(); const { result } = renderHook(() => useWorkOrderTableMutations(onPatch), { wrapper: makeWrapper(), }); act(() => { - result.current.patchField(42, { docStatus: "Yes" }); + result.current.patchField(42, { docStatus: "NN" }); }); - await waitFor(() => expect(events).toContain("update:end")); + await waitFor(() => expect(events).toContain("boardPatch:end")); - expect(update.mock.calls[0]?.[1]).toMatchObject({ docStatus: "Yes" }); - expect(serverState.docStatus).toBe("Yes"); + expect(patchBoardField).toHaveBeenCalledWith(42, { + field: "docStatus", + value: "NotNeeded", + workOrderVersion: "AAAAAAA=", + }); + expect(update).not.toHaveBeenCalled(); + expect(serverState.docStatus).toBe("NN"); }); - it("serializes a completed-date save fired alongside a status save instead of racing", async () => { + it("serializes board docStatus and completedDate full-update without racing", async () => { const onPatch = vi.fn(); const { result } = renderHook(() => useWorkOrderTableMutations(onPatch), { wrapper: makeWrapper(), @@ -126,27 +159,25 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { result.current.patchField(42, { completedDate: "2026-07-20" }); }); - await waitFor(() => expect(events.filter((event) => event === "update:end")).toHaveLength(2)); + await waitFor(() => expect(events).toContain("update:end")); + await waitFor(() => expect(events).toContain("boardPatch:end")); - // The second full-update cycle must not start its GET until the first PUT settled. expect(events).toEqual([ - "getById:start", - "getById:end", - "update:start", - "update:end", + "resolveVersion:start", + "resolveVersion:end", + "boardPatch:start", + "boardPatch:end", "getById:start", "getById:end", "update:start", "update:end", ]); - // Neither save clobbers the other: both the status and the date survive. expect(serverState.docStatus).toBe("Yes"); expect(serverState.completedDate).toBe("2026-07-20"); - - // Scheduling and lifecycle status are untouched by either completion-doc save. expect(serverState.scheduledDate).toBe(BASE_DETAIL.scheduledDate); expect(serverState.status).toBe(BASE_DETAIL.status); + for (const call of update.mock.calls) { const values = call[1] as WorkOrderFormValues; expect(values.scheduledDate).toBe(BASE_DETAIL.scheduledDate);