mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 23:13:12 +00:00
fix(work-orders): persist docStatus via board PATCH with NotNeeded mapping
Map API NotNeeded to UI NN on read. Emit board PATCH Yes|No|NotNeeded on write, and keep DocStatus off the EditWorkorder payload.
This commit is contained in:
parent
b7056f569e
commit
188499155e
10 changed files with 183 additions and 29 deletions
|
|
@ -26,6 +26,8 @@ export const API_PATHS = {
|
|||
addDispatchComment: "WorkOrder/AddDispatchComment",
|
||||
dispatchToVendor: "WorkOrder/DispatchToVendor",
|
||||
changeStatus: "WorkOrder/ChangeStatus",
|
||||
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
||||
boardPatch: (id: string | number) => `workorders/${id}/board`,
|
||||
},
|
||||
pmSchedule: {
|
||||
getList: "PMSchedule/GetList",
|
||||
|
|
|
|||
|
|
@ -149,11 +149,9 @@ export default function WorkOrdersListPage() {
|
|||
if (!open) setDocRow(null);
|
||||
}}
|
||||
data={completionDocs[String(activeDocRow.id)]}
|
||||
// docStatus and completedDate persist server-side through the real work-order
|
||||
// update API; patchField serializes same-id saves so a status save and a
|
||||
// date save never race each other. There is no notes/content persistence
|
||||
// endpoint on this branch, so `data` (notes/generatedAt) stays client-local
|
||||
// for the preview only.
|
||||
// docStatus persists via board PATCH (Yes|No|NotNeeded); completedDate via
|
||||
// EditWorkorder. patchField serializes same-id saves so they never race.
|
||||
// Notes/generatedAt stay client-local (no content endpoint on this branch).
|
||||
onSave={(data, status) => {
|
||||
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
|
||||
patchField(activeDocRow.id, { docStatus: status });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { api, apiDelete, apiGet, apiPost, apiPut, mapHttpStatusToMessage } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
isWoTableMockEnabled,
|
||||
|
|
@ -49,6 +49,21 @@ import {
|
|||
type WorkOrdersListParams,
|
||||
type WorkOrdersListResult,
|
||||
} from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderBoardPatchPayload } from "@/domain/work-orders/types/work-order-board-patch";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readVersionField(record: Record<string, unknown>): string {
|
||||
for (const key of ["rowVersion", "RowVersion", "workOrderVersion", "WorkOrderVersion"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
|
||||
const searchParams: Record<string, string | number> = {
|
||||
|
|
@ -175,6 +190,53 @@ export const workOrdersApi = {
|
|||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
/**
|
||||
* Reads concurrency token from board detail for PATCH …/board.
|
||||
* CompDoc docStatus persists on that contract, which requires workOrderVersion.
|
||||
*/
|
||||
resolveBoardWorkOrderVersion: async (id: string | number): Promise<string> => {
|
||||
if (shouldUseMockWorkOrderDetail(id) || isWoTableMockEnabled()) {
|
||||
return `mock-version-${id}`;
|
||||
}
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.boardDetail(id));
|
||||
const root = asRecord(handleApiResponse(data) ?? data);
|
||||
const info = asRecord(root.info ?? root.Info ?? root);
|
||||
const version = readVersionField(info) || readVersionField(root);
|
||||
if (!version) {
|
||||
throw new Error("Missing work order version for board patch. Refresh and retry.");
|
||||
}
|
||||
return version;
|
||||
},
|
||||
|
||||
/** Persist a single board-editable field (e.g. docStatus Yes|No|NotNeeded). */
|
||||
patchBoardField: async (
|
||||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<void> => {
|
||||
if (shouldUseMockWorkOrderDetail(id) || isWoTableMockEnabled()) {
|
||||
await mockNetworkDelay();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
||||
json: payload,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(mapHttpStatusToMessage(response.status, data));
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string | number,
|
||||
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
||||
|
|
|
|||
|
|
@ -118,11 +118,29 @@ function readWoType(record: Record<string, unknown>): WOType | "" {
|
|||
return "";
|
||||
}
|
||||
|
||||
/** UI uses `NN`; board/API enum uses `NotNeeded` (also numeric 3). */
|
||||
export function mapDocStatusToApi(value: string): string {
|
||||
if (value === "NN") {
|
||||
return "NotNeeded";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readDocStatus(record: Record<string, unknown>): CompletionDocStatus {
|
||||
const rawValue =
|
||||
record.docStatus ?? record.DocStatus ?? record.completionDoc ?? record.CompletionDoc;
|
||||
if (typeof rawValue === "number") {
|
||||
if (rawValue === 1) return "Yes";
|
||||
if (rawValue === 2) return "No";
|
||||
if (rawValue === 3) return "NN";
|
||||
}
|
||||
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
|
||||
if (raw === "Yes" || raw === "No" || raw === "NN") {
|
||||
return raw;
|
||||
}
|
||||
if (raw === "NotNeeded") {
|
||||
return "NN";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
|
|
@ -348,7 +366,7 @@ export function mapWorkOrderFormToCreatePayload(
|
|||
DueDate: values.dueDate || null,
|
||||
ScheduledDate: values.scheduledDate || null,
|
||||
CompletedDate: values.completedDate || null,
|
||||
DocStatus: values.docStatus || null,
|
||||
// DocStatus is a board field — persist via PATCH …/board, not EditWorkorder.
|
||||
AssignTo: values.assignedTo || null,
|
||||
Description: values.description,
|
||||
PO: values.po || null,
|
||||
|
|
@ -383,7 +401,7 @@ export function mapWorkOrderFormToUpdatePayload(
|
|||
VendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
|
||||
ScheduledDate: values.scheduledDate || null,
|
||||
CompletedDate: values.completedDate || null,
|
||||
DocStatus: values.docStatus || null,
|
||||
// DocStatus is a board field — persist via PATCH …/board, not EditWorkorder.
|
||||
CreatedDate: values.createdDate || null,
|
||||
Source: values.source || null,
|
||||
};
|
||||
|
|
|
|||
8
src/domain/work-orders/types/work-order-board-patch.ts
Normal file
8
src/domain/work-orders/types/work-order-board-patch.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** Payload for PATCH /workorders/{id}/board (one field per request). */
|
||||
export interface WorkOrderBoardPatchPayload {
|
||||
field: string;
|
||||
value?: string | null;
|
||||
workOrderVersion: string;
|
||||
dispatchVersion?: string | null;
|
||||
primaryDispatchId?: number | null;
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ export interface WorkOrderDetail {
|
|||
vendorNTE: string;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
/** Completion-document status (API DocStatus/CompletionDoc); persisted via full update. */
|
||||
/** Completion-document status (API DocStatus); persisted via board PATCH. */
|
||||
docStatus: CompletionDocStatus;
|
||||
source: string;
|
||||
comments: WorkOrderComment[];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
mapWizardStatusToApi,
|
||||
} from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||
import {
|
||||
mapDocStatusToApi,
|
||||
mapWorkOrderDetailToFormValues,
|
||||
mapWorkOrderTableRow,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
|
|
@ -44,7 +45,6 @@ function buildFullUpdateValues(
|
|||
dueDate: patch.dueDate ?? values.dueDate,
|
||||
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
|
||||
completedDate: patch.completedDate ?? values.completedDate,
|
||||
docStatus: patch.docStatus ?? values.docStatus,
|
||||
assignedTo: patch.dispatcherName ?? values.assignedTo,
|
||||
problem: patch.pm ?? values.problem,
|
||||
source: patch.type ?? values.source,
|
||||
|
|
@ -65,11 +65,32 @@ function buildFullUpdateValues(
|
|||
};
|
||||
}
|
||||
|
||||
async function persistDocStatus(
|
||||
id: string | number,
|
||||
docStatus: NonNullable<WorkOrderTablePatch["docStatus"]>,
|
||||
) {
|
||||
const workOrderVersion = await workOrdersApi.resolveBoardWorkOrderVersion(id);
|
||||
await workOrdersApi.patchBoardField(id, {
|
||||
field: "docStatus",
|
||||
value: mapDocStatusToApi(docStatus),
|
||||
workOrderVersion,
|
||||
});
|
||||
}
|
||||
|
||||
async function runWorkOrderPatch(
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
useFullUpdate: boolean | undefined,
|
||||
): Promise<WorkOrderTablePatch> {
|
||||
if (patch.docStatus !== undefined) {
|
||||
await persistDocStatus(id, patch.docStatus);
|
||||
const rest: WorkOrderTablePatch = { ...patch };
|
||||
delete rest.docStatus;
|
||||
if (Object.keys(rest).length === 0) {
|
||||
return patch;
|
||||
}
|
||||
return runWorkOrderPatch(id, rest, useFullUpdate);
|
||||
}
|
||||
if (patch.status !== undefined && !useFullUpdate) {
|
||||
const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
|
||||
await workOrdersApi.changeStatus(id, apiStatus);
|
||||
|
|
|
|||
|
|
@ -65,17 +65,17 @@ describe("mapWorkOrderFormToUpdatePayload", () => {
|
|||
expect(payload.LocationId).toBeNull();
|
||||
});
|
||||
|
||||
it("persists the completion-document status alongside the completed date", () => {
|
||||
it("keeps completedDate on EditWorkorder and omits DocStatus (board field)", () => {
|
||||
const values = {
|
||||
...createDefaultWorkOrderFormValues(),
|
||||
createdDate: "2026-01-15",
|
||||
completedDate: "2026-07-20",
|
||||
docStatus: "Yes" as const,
|
||||
docStatus: "NN" as const,
|
||||
};
|
||||
|
||||
const payload = mapWorkOrderFormToUpdatePayload(1, values);
|
||||
|
||||
expect(payload.CompletedDate).toBe("2026-07-20");
|
||||
expect(payload.DocStatus).toBe("Yes");
|
||||
expect(payload).not.toHaveProperty("DocStatus");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapUpliftRequest,
|
||||
mapDocStatusToApi,
|
||||
mapWorkOrderDetail,
|
||||
mapWorkOrderDetailToFormValues,
|
||||
mapWorkOrderDispatchSummary,
|
||||
|
|
@ -39,12 +40,25 @@ describe("mapWorkOrderDetail completion-document status", () => {
|
|||
expect(formValues.completedDate).toBe("2026-07-20");
|
||||
});
|
||||
|
||||
it("maps API NotNeeded to UI NN", () => {
|
||||
expect(mapWorkOrderDetail({ Id: 1, DocStatus: "NotNeeded" }).docStatus).toBe("NN");
|
||||
expect(mapWorkOrderDetail({ Id: 2, docStatus: 3 }).docStatus).toBe("NN");
|
||||
});
|
||||
|
||||
it("falls back to an empty doc status for unknown/missing values", () => {
|
||||
const detail = mapWorkOrderDetail({ Id: 1 });
|
||||
expect(detail.docStatus).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapDocStatusToApi", () => {
|
||||
it("maps NN to NotNeeded and keeps Yes/No", () => {
|
||||
expect(mapDocStatusToApi("NN")).toBe("NotNeeded");
|
||||
expect(mapDocStatusToApi("Yes")).toBe("Yes");
|
||||
expect(mapDocStatusToApi("No")).toBe("No");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapUpliftRequest", () => {
|
||||
it("maps decided uplift fields from PascalCase payload", () => {
|
||||
const result = mapUpliftRequest({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ const getById = vi.fn();
|
|||
const update = vi.fn();
|
||||
const changeStatus = vi.fn();
|
||||
const changeAssignment = vi.fn();
|
||||
const resolveBoardWorkOrderVersion = vi.fn();
|
||||
const patchBoardField = vi.fn();
|
||||
|
||||
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||
workOrdersApi: {
|
||||
|
|
@ -15,6 +17,8 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
|||
update: (...args: unknown[]) => update(...args),
|
||||
changeStatus: (...args: unknown[]) => changeStatus(...args),
|
||||
changeAssignment: (...args: unknown[]) => changeAssignment(...args),
|
||||
resolveBoardWorkOrderVersion: (...args: unknown[]) => resolveBoardWorkOrderVersion(...args),
|
||||
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -68,14 +72,39 @@ const BASE_DETAIL: WorkOrderDetail = {
|
|||
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||
let serverState: WorkOrderDetail;
|
||||
let events: string[];
|
||||
let lastBoardPatch: { field: string; value?: string | null; workOrderVersion: string } | null;
|
||||
|
||||
beforeEach(() => {
|
||||
getById.mockReset();
|
||||
update.mockReset();
|
||||
changeStatus.mockReset();
|
||||
changeAssignment.mockReset();
|
||||
resolveBoardWorkOrderVersion.mockReset();
|
||||
patchBoardField.mockReset();
|
||||
serverState = { ...BASE_DETAIL };
|
||||
events = [];
|
||||
lastBoardPatch = null;
|
||||
|
||||
resolveBoardWorkOrderVersion.mockImplementation(async () => {
|
||||
events.push("resolveVersion:start");
|
||||
await delay(10);
|
||||
events.push("resolveVersion:end");
|
||||
return "AAAAAAA=";
|
||||
});
|
||||
|
||||
patchBoardField.mockImplementation(async (_id: unknown, payload: typeof lastBoardPatch) => {
|
||||
events.push("boardPatch:start");
|
||||
await delay(5);
|
||||
lastBoardPatch = payload;
|
||||
if (payload?.field === "docStatus" && typeof payload.value === "string") {
|
||||
serverState = {
|
||||
...serverState,
|
||||
docStatus:
|
||||
payload.value === "NotNeeded" ? "NN" : (payload.value as WorkOrderDetail["docStatus"]),
|
||||
};
|
||||
}
|
||||
events.push("boardPatch:end");
|
||||
});
|
||||
|
||||
getById.mockImplementation(async () => {
|
||||
events.push("getById:start");
|
||||
|
|
@ -91,7 +120,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
|||
...serverState,
|
||||
scheduledDate: values.scheduledDate,
|
||||
completedDate: values.completedDate,
|
||||
docStatus: values.docStatus ?? serverState.docStatus,
|
||||
status: values.status,
|
||||
};
|
||||
events.push("update:end");
|
||||
|
|
@ -99,23 +127,28 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("maps docStatus into the real full-update payload instead of dropping it", async () => {
|
||||
it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(() => useWorkOrderTableMutations(onPatch), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { docStatus: "Yes" });
|
||||
result.current.patchField(42, { docStatus: "NN" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(events).toContain("update:end"));
|
||||
await waitFor(() => expect(events).toContain("boardPatch:end"));
|
||||
|
||||
expect(update.mock.calls[0]?.[1]).toMatchObject({ docStatus: "Yes" });
|
||||
expect(serverState.docStatus).toBe("Yes");
|
||||
expect(patchBoardField).toHaveBeenCalledWith(42, {
|
||||
field: "docStatus",
|
||||
value: "NotNeeded",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(serverState.docStatus).toBe("NN");
|
||||
});
|
||||
|
||||
it("serializes a completed-date save fired alongside a status save instead of racing", async () => {
|
||||
it("serializes board docStatus and completedDate full-update without racing", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(() => useWorkOrderTableMutations(onPatch), {
|
||||
wrapper: makeWrapper(),
|
||||
|
|
@ -126,27 +159,25 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
|||
result.current.patchField(42, { completedDate: "2026-07-20" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(events.filter((event) => event === "update:end")).toHaveLength(2));
|
||||
await waitFor(() => expect(events).toContain("update:end"));
|
||||
await waitFor(() => expect(events).toContain("boardPatch:end"));
|
||||
|
||||
// The second full-update cycle must not start its GET until the first PUT settled.
|
||||
expect(events).toEqual([
|
||||
"getById:start",
|
||||
"getById:end",
|
||||
"update:start",
|
||||
"update:end",
|
||||
"resolveVersion:start",
|
||||
"resolveVersion:end",
|
||||
"boardPatch:start",
|
||||
"boardPatch:end",
|
||||
"getById:start",
|
||||
"getById:end",
|
||||
"update:start",
|
||||
"update:end",
|
||||
]);
|
||||
|
||||
// Neither save clobbers the other: both the status and the date survive.
|
||||
expect(serverState.docStatus).toBe("Yes");
|
||||
expect(serverState.completedDate).toBe("2026-07-20");
|
||||
|
||||
// Scheduling and lifecycle status are untouched by either completion-doc save.
|
||||
expect(serverState.scheduledDate).toBe(BASE_DETAIL.scheduledDate);
|
||||
expect(serverState.status).toBe(BASE_DETAIL.status);
|
||||
|
||||
for (const call of update.mock.calls) {
|
||||
const values = call[1] as WorkOrderFormValues;
|
||||
expect(values.scheduledDate).toBe(BASE_DETAIL.scheduledDate);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue