mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 06:53:24 +00:00
360 lines
11 KiB
TypeScript
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",
|
|
};
|
|
}
|