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", () => {