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:
Arthur Bassi 2026-07-29 09:23:34 -03:00
parent b7056f569e
commit 188499155e
10 changed files with 183 additions and 29 deletions

View file

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

View file

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

View file

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

View file

@ -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,
};

View 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;
}

View file

@ -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[];

View file

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

View file

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

View file

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

View file

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