From 5b0c758a9fad6604a2734e5ef12cc103a9f47013 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Fri, 18 Sep 2026 14:44:06 -0300 Subject: [PATCH 1/3] fix(workorders): persist manual POC edits via dedicated POC endpoint (SH-379) The UI dropped manual POC edits before they reached the API: the patch mapper listed pocName/pocPhone/pocNotes as local-only keys and the slide-over draft excluded them from Save, so the optimistic edit vanished on refetch and the completion freeze captured the Site contact instead. - Emit one composite POC op from table patches and route it through a new workOrdersApi.updatePoc (PATCH workorders/{id}/poc), reusing the board patch row/error contract (409 conflict with currentState, 422 validation). - Include POC scalars in slide-over edit keys so dirty state and Save carry them; site dialog and slide-over now both persist POC edits. --- src/api/api-paths.ts | 1 + src/domain/work-orders/api/work-orders-api.ts | 99 +++++++++++-------- .../mappers/work-order-board-patch-mapper.ts | 39 ++++++-- .../work-orders/types/work-order-board.ts | 8 ++ .../use-work-order-table-mutations.ts | 23 ++++- .../work-orders/utils/slide-over-draft.ts | 6 +- .../wo-table-unassigned-queue.test.tsx | 2 +- .../work-orders/api/work-orders-api.test.ts | 94 ++++++++++++++++++ .../work-order-board-patch-mapper.test.ts | 34 ++++++- .../utils/slide-over-draft.test.ts | 10 +- 10 files changed, 262 insertions(+), 54 deletions(-) diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 64405aa6..cf282ce5 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -30,6 +30,7 @@ export const API_PATHS = { boardCreate: "workorders/board", boardSearch: "workorders/board/search", boardPatch: (id: string | number) => `workorders/${id}/board`, + pocPatch: (id: string | number) => `workorders/${id}/poc`, boardDetail: (id: string | number) => `workorders/${id}/detail`, comments: (id: string | number) => `workorders/${id}/comments`, commentItem: (workOrderId: string | number, commentId: string | number) => diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index fe6068f5..03778ad3 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -46,6 +46,7 @@ import type { WorkOrderBoardQueryParams, WorkOrderBoardResponseApi, WorkOrderBoardSearchQueryParams, + WorkOrderPocPatchPayload, } from "@/domain/work-orders/types/work-order-board"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; @@ -141,6 +142,54 @@ function mapUser(raw: unknown): WorkOrderUser { }; } +/** Shared request/error handling for board row PATCH endpoints (board + poc). */ +async function patchBoardRow( + operationName: string, + method: "patch", + url: string, + payload: WorkOrderBoardPatchPayload | WorkOrderPocPatchPayload, +): Promise { + const response = await apiRequestRaw( + method, + url, + { json: payload, throwHttpErrors: false }, + operationName, + ); + + let data: unknown; + try { + data = await response.json(); + } catch { + data = undefined; + } + + if (response.ok) { + return mapWorkOrderTableRow(data); + } + + const record = typeof data === "object" && data !== null ? (data as Record) : {}; + + if (response.status === 409) { + const currentStateRaw = record.currentState ?? record.CurrentState; + const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null; + throw new BoardPatchConflictError( + typeof record.message === "string" + ? record.message + : "The record was modified by another user. Refresh and retry.", + currentState, + ); + } + + if (response.status === 422) { + throw new BoardPatchValidationError( + typeof record.code === "string" ? record.code : "ValidationError", + typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data), + ); + } + + throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status); +} + export const workOrdersApi = { ...workOrderBoardDocumentsApi, @@ -313,48 +362,20 @@ export const workOrdersApi = { patchBoardField: async ( id: string | number, payload: WorkOrderBoardPatchPayload, - ): Promise => { - const response = await apiRequestRaw( + ): Promise => + patchBoardRow( + "workOrdersApi.patchBoardField", "patch", API_PATHS.workOrder.boardPatch(id), - { json: payload, throwHttpErrors: false }, - "workOrdersApi.patchBoardField", - ); + payload, + ), - let data: unknown; - try { - data = await response.json(); - } catch { - data = undefined; - } - - if (response.ok) { - return mapWorkOrderTableRow(data); - } - - const record = - typeof data === "object" && data !== null ? (data as Record) : {}; - - if (response.status === 409) { - const currentStateRaw = record.currentState ?? record.CurrentState; - const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null; - throw new BoardPatchConflictError( - typeof record.message === "string" - ? record.message - : "The record was modified by another user. Refresh and retry.", - currentState, - ); - } - - if (response.status === 422) { - throw new BoardPatchValidationError( - typeof record.code === "string" ? record.code : "ValidationError", - typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data), - ); - } - - throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status); - }, + /** SH-379: manual POC override — same row/error contract as the board patch. */ + updatePoc: async ( + id: string | number, + payload: WorkOrderPocPatchPayload, + ): Promise => + patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload), update: async ( id: string | number, diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index 66338697..a24d011e 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -28,9 +28,6 @@ const LOCAL_ONLY_KEYS = new Set([ "techPhone", "location", "locationId", - "pocName", - "pocPhone", - "pocNotes", "title", "priority", "apptStart", @@ -53,16 +50,44 @@ export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key)); } +/** SH-379: composite manual POC override op routed to PATCH …/poc. */ +export type BoardPocOp = { + kind: "poc"; + pocName: string; + pocPhone: string; + pocNotes: string; +}; + +export type BoardOp = BoardFieldOp | BoardPocOp; + +function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] { + const hasPocFields = + patch.pocName !== undefined || patch.pocPhone !== undefined || patch.pocNotes !== undefined; + if (!hasPocFields) { + return []; + } + + return [ + { + kind: "poc", + pocName: patch.pocName ?? "", + pocPhone: patch.pocPhone ?? "", + pocNotes: patch.pocNotes ?? "", + }, + ]; +} + /** - * Expands a UI table patch into ordered board field ops (one PATCH each). - * Schedule/week-only fields are emitted first in a stable order. + * Expands a UI table patch into ordered board ops (one request each). + * Schedule/week-only fields are emitted first in a stable order; the composite + * POC op follows, then scalar fields. */ -export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] { +export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] { if (isLocalOnlyTablePatch(patch)) { return []; } - return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)]; + return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)]; } export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean { diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index ef12e1ba..3bd6f067 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -134,6 +134,14 @@ export interface WorkOrderBoardPatchPayload { primaryDispatchId?: number | null; } +/** Payload for PATCH /workorders/{id}/poc (SH-379 manual POC override). */ +export interface WorkOrderPocPatchPayload { + pocName: string; + pocPhone: string; + pocNotes: string; + workOrderVersion: string; +} + export type WorkOrderBoardEditableField = | "woNumber" | "workOrderType" 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 c0fbbe65..60b200c4 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 @@ -14,6 +14,7 @@ import { buildBoardPatchPayload, expandTablePatchToBoardFieldOps, isLocalOnlyTablePatch, + type BoardOp, } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper"; import type { @@ -53,6 +54,26 @@ export type WorkOrderTablePatchHandlers = { type PatchResult = { kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow }; +async function applyBoardOp( + id: string | number, + op: BoardOp, + row: WorkOrderTableRow, +): Promise { + if ("pocName" in op) { + if (!row.rowVersion) { + throw new Error("workOrderVersion is required for board patch"); + } + return workOrdersApi.updatePoc(id, { + pocName: op.pocName, + pocPhone: op.pocPhone, + pocNotes: op.pocNotes, + workOrderVersion: row.rowVersion, + }); + } + + return workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, row)); +} + async function applyBoardFieldOps( id: string | number, patch: WorkOrderTablePatch, @@ -72,7 +93,7 @@ async function applyBoardFieldOps( let completedOps = 0; for (const op of ops) { try { - latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest)); + latest = await applyBoardOp(id, op, latest); completedOps += 1; } catch (error) { if (completedOps > 0) { diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts index 926e1027..5a8bf237 100644 --- a/src/domain/work-orders/utils/slide-over-draft.ts +++ b/src/domain/work-orders/utils/slide-over-draft.ts @@ -7,7 +7,8 @@ import type { /** * Fields compared for dirty state and included in Save patch. * Only keys that map to board PATCH (or required companions like dispatcherName). - * Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op. + * Primary POC scalars persist via the dedicated POC endpoint (SH-379); + * tech phone is not board-editable — exclude so Save cannot no-op. * additionalContacts is board-editable (SH-218 PATCH field). */ export const SLIDE_OVER_EDIT_KEYS = [ @@ -32,6 +33,9 @@ export const SLIDE_OVER_EDIT_KEYS = [ "vendorId", // company/tech are display companions; board persists vendor via vendorId only "status", + "pocName", + "pocPhone", + "pocNotes", ] as const satisfies ReadonlyArray; type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number]; diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx index 54967ec4..41243780 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -150,7 +150,7 @@ function DayAssignHarness({ dispatcherId: id, dispatcherName: name, }); - const patched = await patchBoardField(item.id, ops[0]!); + const patched = await patchBoardField(item.id, ops[0] as { field: string }); setRow({ ...patched, dispatcherId: id, dispatcherName: name }); }} /> diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index e295ed9d..7caf2f48 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -658,6 +658,100 @@ describe("workOrdersApi.patchBoardField", () => { }); }); +describe("workOrdersApi.updatePoc", () => { + beforeEach(() => { + apiPatchFn.mockReset(); + }); + + it("PATCHes poc payload to workorders/{id}/poc and maps row", async () => { + apiPatchFn.mockResolvedValue({ + ok: true, + json: async () => ({ + id: 10, + siteCode: "BK5", + pocName: "Manual POC", + pocPhone: "312-555-0001", + pocNotes: "Call first", + lifecycleStatusLabel: "Scheduled", + rowVersion: "AAAAAAAB", + dispatchRowVersion: null, + primaryDispatchId: null, + vendorId: null, + }), + }); + + const row = await workOrdersApi.updatePoc(10, { + pocName: "Manual POC", + pocPhone: "312-555-0001", + pocNotes: "Call first", + workOrderVersion: "AAAAAAA=", + }); + + expect(apiPatchFn).toHaveBeenCalledWith(API_PATHS.workOrder.pocPatch(10), { + json: { + pocName: "Manual POC", + pocPhone: "312-555-0001", + pocNotes: "Call first", + workOrderVersion: "AAAAAAA=", + }, + throwHttpErrors: false, + }); + expect(row.pocName).toBe("Manual POC"); + expect(row.rowVersion).toBe("AAAAAAAB"); + }); + + it("throws BoardPatchConflictError with currentState on 409", async () => { + apiPatchFn.mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ + message: "The record was modified by another user. Refresh and retry.", + currentState: { + id: 10, + siteCode: "BK5", + rowVersion: "NEWVER==", + lifecycleStatusLabel: "Scheduled", + }, + }), + }); + + const { BoardPatchConflictError } = + await import("@/domain/work-orders/errors/board-patch-errors"); + + await expect( + workOrdersApi.updatePoc(10, { + pocName: "Manual POC", + pocPhone: "312-555-0001", + pocNotes: "", + workOrderVersion: "STALE==", + }), + ).rejects.toBeInstanceOf(BoardPatchConflictError); + }); + + it("throws BoardPatchValidationError on 422 ReadOnly", async () => { + apiPatchFn.mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ + code: "ReadOnly", + message: "Work order is read-only in its current status.", + }), + }); + + await expect( + workOrdersApi.updatePoc(10, { + pocName: "Manual POC", + pocPhone: "312-555-0001", + pocNotes: "", + workOrderVersion: "AAAAAAA=", + }), + ).rejects.toMatchObject({ + name: "BoardPatchValidationError", + code: "ReadOnly", + }); + }); +}); + describe("workOrdersApi.createComment", () => { beforeEach(() => { apiPost.mockReset(); diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index 0d973632..e7ebd9b0 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -34,6 +34,12 @@ describe("isLocalOnlyTablePatch", () => { it("treats scheduleEnd and schedMode as local-only overlays", () => { expect(isLocalOnlyTablePatch({ scheduleEnd: "2026-06-12", schedMode: "range" })).toBe(true); }); + + it("does not treat POC edits as local-only (SH-379)", () => { + expect(isLocalOnlyTablePatch({ pocName: "Pat", pocPhone: "111", pocNotes: "call" })).toBe( + false, + ); + }); }); describe("expandTablePatchToBoardFieldOps", () => { @@ -185,7 +191,33 @@ describe("expandTablePatchToBoardFieldOps", () => { pocPhone: "111", pocNotes: "call", }), - ).toEqual([{ field: "siteCode", value: "BK5" }]); + ).toEqual([ + { + kind: "poc", + pocName: "Pat", + pocPhone: "111", + pocNotes: "call", + }, + { field: "siteCode", value: "BK5" }, + ]); + }); + + it("emits one composite POC op before scalar ops (SH-379)", () => { + expect( + expandTablePatchToBoardFieldOps({ + pocName: "Pat", + pocNotes: "call first", + docStatus: "Yes", + }), + ).toEqual([ + { + kind: "poc", + pocName: "Pat", + pocPhone: "", + pocNotes: "call first", + }, + { field: "docStatus", value: "Yes" }, + ]); }); it("keeps dueDate and scheduledDate patches independent", () => { 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 615e1085..322391af 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 @@ -67,7 +67,9 @@ describe("slide-over-draft", () => { expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId"); expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts"); expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired"); - expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName"); + expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName"); + expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone"); + expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company"); @@ -85,11 +87,11 @@ describe("slide-over-draft", () => { expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" }); }); - it("ignores POC-only edits for dirty state", () => { + it("tracks POC edits for dirty state and patch (SH-379)", () => { const row = baseRow(); const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" }); - expect(isSlideOverDraftDirty(draft, row)).toBe(false); - expect(buildSlideOverPatch(draft, row)).toEqual({}); + expect(isSlideOverDraftDirty(draft, row)).toBe(true); + expect(buildSlideOverPatch(draft, row)).toEqual({ pocName: "Jordan", pocPhone: "555-0100" }); }); it("detects dirty draft when a tracked field changes", () => { From 3b1da21abb366b2340e5ccf57206dc18ca5679cd Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Fri, 18 Sep 2026 15:00:01 -0300 Subject: [PATCH 2/3] fix(workorders): preserve partial POC edits --- src/domain/work-orders/api/work-orders-api.ts | 2 +- .../mappers/work-order-board-patch-mapper.ts | 14 +++---- .../work-orders/types/work-order-board.ts | 2 +- .../use-work-order-table-mutations.ts | 12 ++++-- .../work-orders/utils/slide-over-draft.ts | 2 +- .../work-order-board-patch-mapper.test.ts | 6 +-- .../use-work-order-table-mutations.test.tsx | 37 +++++++++++++++++++ .../utils/slide-over-draft.test.ts | 2 +- 8 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index 03778ad3..fb02637a 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -370,7 +370,7 @@ export const workOrdersApi = { payload, ), - /** SH-379: manual POC override — same row/error contract as the board patch. */ + /** Manual POC override — same row/error contract as the board patch. */ updatePoc: async ( id: string | number, payload: WorkOrderPocPatchPayload, diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index a24d011e..65a5dc02 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -50,12 +50,12 @@ export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key)); } -/** SH-379: composite manual POC override op routed to PATCH …/poc. */ +/** Composite manual POC override op routed to PATCH …/poc. */ export type BoardPocOp = { kind: "poc"; - pocName: string; - pocPhone: string; - pocNotes: string; + pocName?: string; + pocPhone?: string; + pocNotes?: string; }; export type BoardOp = BoardFieldOp | BoardPocOp; @@ -70,9 +70,9 @@ function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] { return [ { kind: "poc", - pocName: patch.pocName ?? "", - pocPhone: patch.pocPhone ?? "", - pocNotes: patch.pocNotes ?? "", + pocName: patch.pocName, + pocPhone: patch.pocPhone, + pocNotes: patch.pocNotes, }, ]; } diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 3bd6f067..3b06e4fb 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -134,7 +134,7 @@ export interface WorkOrderBoardPatchPayload { primaryDispatchId?: number | null; } -/** Payload for PATCH /workorders/{id}/poc (SH-379 manual POC override). */ +/** Payload for PATCH /workorders/{id}/poc (manual POC override). */ export interface WorkOrderPocPatchPayload { pocName: string; pocPhone: 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 60b200c4..f7684958 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 @@ -54,19 +54,23 @@ export type WorkOrderTablePatchHandlers = { type PatchResult = { kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow }; +function isPocOp(op: BoardOp): op is Extract { + return "kind" in op && op.kind === "poc"; +} + async function applyBoardOp( id: string | number, op: BoardOp, row: WorkOrderTableRow, ): Promise { - if ("pocName" in op) { + if (isPocOp(op)) { if (!row.rowVersion) { throw new Error("workOrderVersion is required for board patch"); } return workOrdersApi.updatePoc(id, { - pocName: op.pocName, - pocPhone: op.pocPhone, - pocNotes: op.pocNotes, + pocName: op.pocName ?? row.pocName, + pocPhone: op.pocPhone ?? row.pocPhone, + pocNotes: op.pocNotes ?? row.pocNotes, workOrderVersion: row.rowVersion, }); } diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts index 5a8bf237..7135bd01 100644 --- a/src/domain/work-orders/utils/slide-over-draft.ts +++ b/src/domain/work-orders/utils/slide-over-draft.ts @@ -7,7 +7,7 @@ import type { /** * Fields compared for dirty state and included in Save patch. * Only keys that map to board PATCH (or required companions like dispatcherName). - * Primary POC scalars persist via the dedicated POC endpoint (SH-379); + * Primary POC scalars persist via the dedicated POC endpoint; * tech phone is not board-editable — exclude so Save cannot no-op. * additionalContacts is board-editable (SH-218 PATCH field). */ diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index e7ebd9b0..ab852413 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -35,7 +35,7 @@ describe("isLocalOnlyTablePatch", () => { expect(isLocalOnlyTablePatch({ scheduleEnd: "2026-06-12", schedMode: "range" })).toBe(true); }); - it("does not treat POC edits as local-only (SH-379)", () => { + it("does not treat POC edits as local-only", () => { expect(isLocalOnlyTablePatch({ pocName: "Pat", pocPhone: "111", pocNotes: "call" })).toBe( false, ); @@ -202,7 +202,7 @@ describe("expandTablePatchToBoardFieldOps", () => { ]); }); - it("emits one composite POC op before scalar ops (SH-379)", () => { + it("emits one composite POC op before scalar ops", () => { expect( expandTablePatchToBoardFieldOps({ pocName: "Pat", @@ -213,7 +213,7 @@ describe("expandTablePatchToBoardFieldOps", () => { { kind: "poc", pocName: "Pat", - pocPhone: "", + pocPhone: undefined, pocNotes: "call first", }, { field: "docStatus", value: "Yes" }, 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 97f364b5..a2a6798c 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 @@ -9,12 +9,14 @@ import type { const getById = vi.fn(); const update = vi.fn(); +const updatePoc = vi.fn(); const patchBoardField = vi.fn(); vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ workOrdersApi: { getById: (...args: unknown[]) => getById(...args), update: (...args: unknown[]) => update(...args), + updatePoc: (...args: unknown[]) => updatePoc(...args), patchBoardField: (...args: unknown[]) => patchBoardField(...args), }, })); @@ -121,6 +123,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { beforeEach(() => { getById.mockReset(); update.mockReset(); + updatePoc.mockReset(); patchBoardField.mockReset(); serverState = { ...BASE_SERVER }; rowState = { ...BASE_ROW }; @@ -829,6 +832,40 @@ describe("useWorkOrderTableMutations type and severity", () => { }); }); +describe("useWorkOrderTableMutations POC persistence", () => { + it("fills omitted POC fields from the current row before the full replacement", async () => { + const currentRow = { + ...BASE_ROW, + pocName: "Alex", + pocPhone: "555-0000", + pocNotes: "Gate", + }; + updatePoc.mockResolvedValue({ ...currentRow, pocName: "Jordan", rowVersion: "wo-v2" }); + + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: () => undefined, + getRow: () => currentRow, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { pocName: "Jordan" }); + }); + + await waitFor(() => expect(updatePoc).toHaveBeenCalled()); + expect(updatePoc).toHaveBeenCalledWith(42, { + pocName: "Jordan", + pocPhone: "555-0000", + pocNotes: "Gate", + workOrderVersion: "AAAAAAA=", + }); + }); +}); + describe("useWorkOrderTableMutations audit cache refresh", () => { it("invalidates work-order queries after a successful status transition", async () => { const client = new QueryClient({ 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 322391af..2bc85efc 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 @@ -87,7 +87,7 @@ describe("slide-over-draft", () => { expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" }); }); - it("tracks POC edits for dirty state and patch (SH-379)", () => { + it("tracks POC edits for dirty state and patch", () => { const row = baseRow(); const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" }); expect(isSlideOverDraftDirty(draft, row)).toBe(true); From 84568dd73792b6cf6e386505cd7281ee585e426d Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Fri, 18 Sep 2026 15:03:59 -0300 Subject: [PATCH 3/3] refactor(workorders): extract board patch api module work-orders-api.ts exceeded the 500-line godfile cap after adding updatePoc. Move patchBoardField/updatePoc and their shared response handling into work-order-board-patch-api.ts and re-export through workOrdersApi, mirroring workOrderBoardDocumentsApi. --- .../api/work-order-board-patch-api.ts | 81 +++++++++++++++++++ src/domain/work-orders/api/work-orders-api.ts | 76 +---------------- 2 files changed, 85 insertions(+), 72 deletions(-) create mode 100644 src/domain/work-orders/api/work-order-board-patch-api.ts diff --git a/src/domain/work-orders/api/work-order-board-patch-api.ts b/src/domain/work-orders/api/work-order-board-patch-api.ts new file mode 100644 index 00000000..819ddf69 --- /dev/null +++ b/src/domain/work-orders/api/work-order-board-patch-api.ts @@ -0,0 +1,81 @@ +import { API_PATHS } from "@/api/api-paths"; +import { ApiError, mapHttpStatusToMessage } from "@/api/api-error"; +import { apiRequestRaw } from "@/api/api"; +import { + BoardPatchConflictError, + BoardPatchValidationError, +} from "@/domain/work-orders/errors/board-patch-errors"; +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper"; +import type { + WorkOrderBoardPatchPayload, + WorkOrderPocPatchPayload, +} from "@/domain/work-orders/types/work-order-board"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +/** Shared request/error handling for board row PATCH endpoints (board + poc). */ +async function patchBoardRow( + operationName: string, + method: "patch", + url: string, + payload: WorkOrderBoardPatchPayload | WorkOrderPocPatchPayload, +): Promise { + const response = await apiRequestRaw( + method, + url, + { json: payload, throwHttpErrors: false }, + operationName, + ); + + let data: unknown; + try { + data = await response.json(); + } catch { + data = undefined; + } + + if (response.ok) { + return mapWorkOrderTableRow(data); + } + + const record = typeof data === "object" && data !== null ? (data as Record) : {}; + + if (response.status === 409) { + const currentStateRaw = record.currentState ?? record.CurrentState; + const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null; + throw new BoardPatchConflictError( + typeof record.message === "string" + ? record.message + : "The record was modified by another user. Refresh and retry.", + currentState, + ); + } + + if (response.status === 422) { + throw new BoardPatchValidationError( + typeof record.code === "string" ? record.code : "ValidationError", + typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data), + ); + } + + throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status); +} + +export const workOrderBoardPatchApi = { + patchBoardField: async ( + id: string | number, + payload: WorkOrderBoardPatchPayload, + ): Promise => + patchBoardRow( + "workOrdersApi.patchBoardField", + "patch", + API_PATHS.workOrder.boardPatch(id), + payload, + ), + + /** Manual POC override — same row/error contract as the board patch. */ + updatePoc: async ( + id: string | number, + payload: WorkOrderPocPatchPayload, + ): Promise => + patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload), +}; diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index fb02637a..35669740 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -1,12 +1,8 @@ import { API_PATHS } from "@/api/api-paths"; -import { ApiError, mapHttpStatusToMessage } from "@/api/api-error"; -import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api"; +import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; -import { - BoardPatchConflictError, - BoardPatchValidationError, -} from "@/domain/work-orders/errors/board-patch-errors"; import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api"; +import { workOrderBoardPatchApi } from "@/domain/work-orders/api/work-order-board-patch-api"; import { mapDispatchComment, mapDispatchDetail, @@ -20,7 +16,6 @@ import { mapWorkOrderDetail, mapWorkOrderFormToCreatePayload, mapWorkOrderFormToUpdateFormData, - mapWorkOrderTableRow, mapWorkOrderTableRows, } from "@/domain/work-orders/mappers/work-order-mapper"; import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper"; @@ -42,11 +37,9 @@ import type { import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderBoardListResult, - WorkOrderBoardPatchPayload, WorkOrderBoardQueryParams, WorkOrderBoardResponseApi, WorkOrderBoardSearchQueryParams, - WorkOrderPocPatchPayload, } from "@/domain/work-orders/types/work-order-board"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; @@ -142,54 +135,6 @@ function mapUser(raw: unknown): WorkOrderUser { }; } -/** Shared request/error handling for board row PATCH endpoints (board + poc). */ -async function patchBoardRow( - operationName: string, - method: "patch", - url: string, - payload: WorkOrderBoardPatchPayload | WorkOrderPocPatchPayload, -): Promise { - const response = await apiRequestRaw( - method, - url, - { json: payload, throwHttpErrors: false }, - operationName, - ); - - let data: unknown; - try { - data = await response.json(); - } catch { - data = undefined; - } - - if (response.ok) { - return mapWorkOrderTableRow(data); - } - - const record = typeof data === "object" && data !== null ? (data as Record) : {}; - - if (response.status === 409) { - const currentStateRaw = record.currentState ?? record.CurrentState; - const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null; - throw new BoardPatchConflictError( - typeof record.message === "string" - ? record.message - : "The record was modified by another user. Refresh and retry.", - currentState, - ); - } - - if (response.status === 422) { - throw new BoardPatchValidationError( - typeof record.code === "string" ? record.code : "ValidationError", - typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data), - ); - } - - throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status); -} - export const workOrdersApi = { ...workOrderBoardDocumentsApi, @@ -359,23 +304,10 @@ export const workOrdersApi = { return mapWorkOrderDetail(handleApiResponse(data)); }, - patchBoardField: async ( - id: string | number, - payload: WorkOrderBoardPatchPayload, - ): Promise => - patchBoardRow( - "workOrdersApi.patchBoardField", - "patch", - API_PATHS.workOrder.boardPatch(id), - payload, - ), + patchBoardField: workOrderBoardPatchApi.patchBoardField, /** Manual POC override — same row/error contract as the board patch. */ - updatePoc: async ( - id: string | number, - payload: WorkOrderPocPatchPayload, - ): Promise => - patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload), + updatePoc: workOrderBoardPatchApi.updatePoc, update: async ( id: string | number,