mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 17:53:12 +00:00
232 lines
7.2 KiB
TypeScript
232 lines
7.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 {
|
|
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 { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
|
import { queryKeys } from "@/infra/query-key/query-key";
|
|
|
|
export type { WorkOrderTablePatch };
|
|
|
|
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({
|
|
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
|
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
|
onMutate: async ({ id, patch }) => {
|
|
onPatch(id, patch);
|
|
},
|
|
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(error.message);
|
|
} else {
|
|
clearPatch(id);
|
|
toast.error(error.message || "Failed to update work order");
|
|
}
|
|
invalidate();
|
|
},
|
|
});
|
|
|
|
const patchField = (
|
|
id: string | number,
|
|
patch: WorkOrderTablePatch,
|
|
options?: {
|
|
onSuccess?: () => void;
|
|
onError?: (error: Error) => void;
|
|
},
|
|
) => {
|
|
patchMutation.mutate(
|
|
{ id, patch },
|
|
{
|
|
onSuccess: () => options?.onSuccess?.(),
|
|
onError: (error) => options?.onError?.(error),
|
|
},
|
|
);
|
|
};
|
|
|
|
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
|
|
patchMutation.mutateAsync({ id, patch });
|
|
|
|
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,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
|
|
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
|
const hasDate = !!row.scheduledOn;
|
|
const hasDispatcher = !!row.dispatcherId;
|
|
if (hasDate && hasDispatcher && row.status === "Incomplete") {
|
|
return { status: "Scheduled" };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Build a schedule table patch. Clearing the schedule optimistically clears
|
|
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
|
|
*/
|
|
export function applySchedulePatch(
|
|
scheduledOn: string,
|
|
scheduleWeekOnly: boolean,
|
|
): WorkOrderTablePatch {
|
|
if (scheduleWeekOnly) {
|
|
return {
|
|
scheduledOn: "",
|
|
targetWeek: scheduledOn,
|
|
scheduleWeekOnly: true,
|
|
...deriveDayFields(""),
|
|
};
|
|
}
|
|
const trimmed = scheduledOn.trim();
|
|
if (!trimmed) {
|
|
return {
|
|
scheduledOn: "",
|
|
scheduleWeekOnly: false,
|
|
targetWeek: "",
|
|
isAddOn: false,
|
|
...deriveDayFields(""),
|
|
};
|
|
}
|
|
return {
|
|
scheduledOn: trimmed,
|
|
scheduleWeekOnly: false,
|
|
targetWeek: "",
|
|
...deriveDayFields(trimmed),
|
|
};
|
|
}
|