shoc-frontend-new/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts

360 lines
11 KiB
TypeScript

import { useMemo } from "react";
import { type QueryClient, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import {
boardPatchErrorMessage,
BoardPatchConflictError,
BoardPatchPartialSaveError,
BoardPatchValidationError,
NO_PERSISTABLE_FIELDS_MESSAGE,
} from "@/domain/work-orders/errors/board-patch-errors";
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
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 { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
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;
/** Patch base when the loaded list no longer holds the work order (e.g. moved to another week). */
fallbackRow?: WorkOrderTableRow;
};
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 };
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,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<WorkOrderTableRow> {
const current = getRow?.(id);
const ops = expandTablePatchToBoardFieldOps(patch, current);
if (ops.length === 0) {
throw new BoardPatchValidationError("NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE);
}
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 applyBoardOp(id, op, latest);
completedOps += 1;
} catch (error) {
if (completedOps > 0) {
throw new BoardPatchPartialSaveError(latest, error);
}
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 };
}
function readDetailRow(
queryClient: QueryClient,
id: string | number,
): WorkOrderTableRow | undefined {
return queryClient.getQueryData<WorkOrderBoardDetail>(queryKeys.workOrders.boardDetail(id))?.info;
}
/** Carry a saved row into the open panel's detail before the next queued patch runs. */
function syncDetailRow(queryClient: QueryClient, id: string | number, row: WorkOrderTableRow) {
queryClient.setQueryData<WorkOrderBoardDetail>(queryKeys.workOrders.boardDetail(id), (detail) =>
detail && row.rowVersion
? {
...detail,
// The whole saved row, not just its version: a queued patch built from fresh
// version + stale fields would silently revert what the previous save wrote.
info: {
...detail.info,
...row,
dispatchRowVersion: row.dispatchRowVersion || detail.info.dispatchRowVersion,
mediaCount: detail.info.mediaCount,
},
}
: detail,
);
}
type SavedRowSync = {
queryClient: QueryClient;
/** Last server row each patch returned, for panels whose detail query never cached data. */
savedRows: Map<string, WorkOrderTableRow>;
};
function recordSavedRow(
{ queryClient, savedRows }: SavedRowSync,
id: string | number,
row: WorkOrderTableRow,
) {
savedRows.set(String(id), row);
syncDetailRow(queryClient, id, row);
}
async function runPatchAndSyncDetail(
sync: SavedRowSync,
id: string | number,
patch: WorkOrderTablePatch,
readBase: (id: string | number) => WorkOrderTableRow | undefined,
): Promise<PatchResult> {
try {
const result = await runWorkOrderTablePatch(id, patch, readBase);
if (result.kind === "board") recordSavedRow(sync, id, result.row);
return result;
} catch (error) {
if (error instanceof BoardPatchPartialSaveError) {
recordSavedRow(sync, id, error.partialRow);
} else if (error instanceof BoardPatchConflictError && error.currentState) {
// A retry before the refetch lands must patch the server's current version.
recordSavedRow(sync, id, error.currentState);
}
throw error;
}
}
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 savedRows = useMemo(() => new Map<string, WorkOrderTableRow>(), []);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
meta: { suppressErrorToast: true },
mutationFn: ({
id,
persistPatch,
baseRow,
fallbackRow,
}: {
id: string | number;
patch: WorkOrderTablePatch;
persistPatch: WorkOrderTablePatch;
/** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */
baseRow?: WorkOrderTableRow;
/** Used when neither the table nor the detail cache holds the work order. */
fallbackRow?: WorkOrderTableRow;
}) =>
enqueuePatch(id, () =>
runPatchAndSyncDetail(
{ queryClient, savedRows },
id,
persistPatch,
baseRow
? () => baseRow
: (rowId) =>
getRow?.(rowId) ??
readDetailRow(queryClient, rowId) ??
savedRows.get(String(rowId)) ??
fallbackRow,
),
),
onMutate: async ({ id, persistPatch }) => {
onPatch(id, persistPatch);
},
onSuccess: (result, { id }) => {
if (result.kind === "board") {
onPatch(id, result.row);
}
invalidate();
},
onError: (error: Error, { id }) => {
if (error instanceof BoardPatchPartialSaveError) {
onPatch(id, error.partialRow);
toast.error(error.message);
invalidate();
return;
}
if (error instanceof BoardPatchConflictError && error.currentState) {
onPatch(id, error.currentState);
toast.error(boardPatchErrorMessage(error));
} else {
clearPatch(id);
toast.error(boardPatchErrorMessage(error));
}
invalidate();
},
});
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
const fallbackRow = options?.fallbackRow;
const persistPatch = mergeAutoSchedulePatch(getRow?.(id) ?? fallbackRow, patch);
void patchMutation.mutateAsync({ id, patch, persistPatch, fallbackRow }).then(
() => options?.onSuccess?.(),
(error: Error) => options?.onError?.(error),
);
};
const patchFieldAsync = (
id: string | number,
patch: WorkOrderTablePatch,
baseRow?: WorkOrderTableRow,
fallbackRow?: WorkOrderTableRow,
) => {
const persistPatch = mergeAutoSchedulePatch(baseRow ?? getRow?.(id) ?? fallbackRow, patch);
return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow, fallbackRow });
};
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",
};
}