mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 22:43:23 +00:00
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:
parent
d6510b262e
commit
5b0c758a9f
10 changed files with 262 additions and 54 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue