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.
This commit is contained in:
Codex Review Integration 2026-09-18 14:44:06 -03:00
parent d6510b262e
commit 5b0c758a9f
10 changed files with 262 additions and 54 deletions

View file

@ -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) =>

View file

@ -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<WorkOrderTableRow> {
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<string, unknown>) : {};
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<WorkOrderTableRow> => {
const response = await apiRequestRaw(
): Promise<WorkOrderTableRow> =>
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<string, unknown>) : {};
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<WorkOrderTableRow> =>
patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload),
update: async (
id: string | number,

View file

@ -28,9 +28,6 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"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 {

View file

@ -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"

View file

@ -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<WorkOrderTableRow> {
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) {

View file

@ -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<keyof WorkOrderTableRow>;
type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number];

View file

@ -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 });
}}
/>

View file

@ -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();

View file

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

View file

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