shoc-frontend-new/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
Codex Review Integration 09636ef590 fix(work-orders): complete the work order when its completion document is generated (SH-341)
Generating or uploading the completion document now moves a closable work
order to Completed without the manual confirmation, as SH-115 requires. The
SH-114 closability gate is evaluated on the row the server returned after the
document save; Save draft, Not required, Past Due, Completed and Canceled rows
are never auto-completed. The follow-up status patch uses that row's
rowVersion so it does not conflict with the document save.
2026-09-16 11:43:33 -03:00

268 lines
8.2 KiB
TypeScript

import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import {
boardPatchValidationMessage,
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import {
buildBoardPatchPayload,
expandTablePatchToBoardFieldOps,
isLocalOnlyTablePatch,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import {
maybeAutoSchedulePatch,
mergeAutoSchedulePatch,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
import { queryKeys } from "@/infra/query-key/query-key";
export { maybeAutoSchedulePatch };
export type { WorkOrderTablePatch };
export type WorkOrderTablePatchOptions = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
export type WorkOrderTablePatchFn = (
id: string | number,
patch: WorkOrderTablePatch,
options?: WorkOrderTablePatchOptions,
) => void;
export type WorkOrderTablePatchHandlers = {
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
clearPatch: (id: string | number) => void;
getRow?: (id: string | number) => WorkOrderTableRow | undefined;
};
type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
async function applyBoardFieldOps(
id: string | number,
patch: WorkOrderTablePatch,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<WorkOrderTableRow> {
const ops = expandTablePatchToBoardFieldOps(patch);
if (ops.length === 0) {
throw new BoardPatchValidationError(
"NoPersistableFields",
"These changes cannot be saved on the board. Refresh and edit a supported field.",
);
}
const current = getRow?.(id);
if (!current?.rowVersion) {
throw new Error("Missing work order version for board patch. Refresh and retry.");
}
let latest = current;
let completedOps = 0;
for (const op of ops) {
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
completedOps += 1;
} catch (error) {
if (completedOps > 0) {
const field = op.field;
const partial = new Error(
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
throw partial;
}
throw error;
}
}
return latest;
}
/** Board fields (including completedDate) use PATCH …/board — never legacy EditWorkorder. */
export async function runWorkOrderTablePatch(
id: string | number,
patch: WorkOrderTablePatch,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<PatchResult> {
if (isLocalOnlyTablePatch(patch)) {
return { kind: "local", patch };
}
const boardRow = await applyBoardFieldOps(id, patch, getRow);
return { kind: "board", row: boardRow };
}
export function useWorkOrderTableMutations(
onPatchOrHandlers:
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
getRowArg?: (id: string | number) => WorkOrderTableRow | undefined,
) {
const handlers: WorkOrderTablePatchHandlers =
typeof onPatchOrHandlers === "function"
? {
onPatch: onPatchOrHandlers,
clearPatch: () => undefined,
getRow: getRowArg,
}
: onPatchOrHandlers;
const { onPatch, clearPatch, getRow } = handlers;
const queryClient = useQueryClient();
const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
meta: { suppressErrorToast: true },
mutationFn: ({
id,
persistPatch,
baseRow,
}: {
id: string | number;
patch: WorkOrderTablePatch;
persistPatch: WorkOrderTablePatch;
/** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */
baseRow?: WorkOrderTableRow;
}) =>
enqueuePatch(id, () =>
runWorkOrderTablePatch(id, persistPatch, baseRow ? () => baseRow : getRow),
),
onMutate: async ({ id, persistPatch }) => {
onPatch(id, persistPatch);
},
onSuccess: (result, { id }) => {
if (result.kind === "board") {
onPatch(id, result.row);
}
invalidate();
},
onError: (error: Error, { id }) => {
const partialRow = (error as Error & { partialRow?: WorkOrderTableRow }).partialRow;
if (partialRow) {
onPatch(id, partialRow);
toast.error(error.message);
invalidate();
return;
}
if (error instanceof BoardPatchConflictError && error.currentState) {
onPatch(id, error.currentState);
toast.error(error.message || "Conflict — refreshed with latest data");
} else if (error instanceof BoardPatchValidationError) {
clearPatch(id);
toast.error(boardPatchValidationMessage(error));
} else {
clearPatch(id);
toast.error(error.message || "Failed to update work order");
}
invalidate();
},
});
const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) =>
mergeAutoSchedulePatch(getRow?.(id), patch);
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
const persistPatch = resolvePatch(id, patch);
void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
() => options?.onSuccess?.(),
(error: Error) => options?.onError?.(error),
);
};
const patchFieldAsync = (
id: string | number,
patch: WorkOrderTablePatch,
baseRow?: WorkOrderTableRow,
) => {
const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch);
return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow });
};
return {
patchField,
patchFieldAsync,
isPending: patchMutation.isPending,
};
}
export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersApi.getById>>) {
return enrichTableRow(
mapWorkOrderTableRow({
...detail,
status: mapApiStatusToWizard(detail.status),
scheduledOn: detail.scheduledDate,
woNumber: detail.internalWONumber || detail.workOrderNumber,
dispatcherName: detail.assignedTo,
pm: detail.problem,
type: detail.source,
}),
);
}
/**
* Build a schedule table patch. Clearing the schedule optimistically clears
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response.
*/
export function applySchedulePatch(
scheduledOn: string,
scheduleWeekOnly: boolean,
): WorkOrderTablePatch {
if (scheduleWeekOnly) {
return {
scheduledOn: "",
targetWeek: scheduledOn,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
...deriveDayFields(""),
};
}
const trimmed = scheduledOn.trim();
if (!trimmed) {
return {
scheduledOn: "",
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: undefined,
isAddOn: false,
...deriveDayFields(""),
};
}
return {
scheduledOn: trimmed,
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: "date",
...deriveDayFields(trimmed),
};
}
/** Range lands on the first day; end/mode stay optimistic (no board API field). */
export function applyScheduleRangePatch(start: string, end: string): WorkOrderTablePatch {
return {
...applySchedulePatch(start, false),
scheduleEnd: end,
schedMode: "range",
};
}