Merge pull request #228 from Sea-Haven-Industries/fix/ab/sh-379-poc-persist

Persist manual POC edits via dedicated POC endpoint (SH-379)
This commit is contained in:
Alexandre Brandizzi 2026-09-18 23:33:52 +00:00 • committed by GitHub
commit f25a3dd628
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 329 additions and 67 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`,
completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`,

View file

@ -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<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 workOrderBoardPatchApi = {
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> =>
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<WorkOrderTableRow> =>
patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload),
};

View file

@ -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,7 +37,6 @@ import type {
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import type {
WorkOrderBoardListResult,
WorkOrderBoardPatchPayload,
WorkOrderBoardQueryParams,
WorkOrderBoardResponseApi,
WorkOrderBoardSearchQueryParams,
@ -310,51 +304,10 @@ export const workOrdersApi = {
return mapWorkOrderDetail(handleApiResponse(data));
},
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> => {
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.boardPatch(id),
{ json: payload, throwHttpErrors: false },
"workOrdersApi.patchBoardField",
);
patchBoardField: workOrderBoardPatchApi.patchBoardField,
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);
},
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: workOrderBoardPatchApi.updatePoc,
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));
}
/** 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 (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,30 @@ export type WorkOrderTablePatchHandlers = {
type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
function isPocOp(op: BoardOp): op is Extract<BoardOp, { kind: "poc" }> {
return "kind" in op && op.kind === "poc";
}
async function applyBoardOp(
id: string | number,
op: BoardOp,
row: WorkOrderTableRow,
): Promise<WorkOrderTableRow> {
if (isPocOp(op)) {
if (!row.rowVersion) {
throw new Error("workOrderVersion is required for board patch");
}
return workOrdersApi.updatePoc(id, {
pocName: op.pocName ?? row.pocName,
pocPhone: op.pocPhone ?? row.pocPhone,
pocNotes: op.pocNotes ?? row.pocNotes,
workOrderVersion: row.rowVersion,
});
}
return workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, row));
}
async function applyBoardFieldOps(
id: string | number,
patch: WorkOrderTablePatch,
@ -72,7 +97,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;
* 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", () => {
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", () => {
expect(
expandTablePatchToBoardFieldOps({
pocName: "Pat",
pocNotes: "call first",
docStatus: "Yes",
}),
).toEqual([
{
kind: "poc",
pocName: "Pat",
pocPhone: undefined,
pocNotes: "call first",
},
{ field: "docStatus", value: "Yes" },
]);
});
it("keeps dueDate and scheduledDate patches independent", () => {

View file

@ -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({

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