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 { return "kind" in op && op.kind === "poc"; } async function applyBoardOp( id: string | number, op: BoardOp, row: WorkOrderTableRow, ): Promise { 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 { 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 { 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(queryKeys.workOrders.boardDetail(id))?.info; } /** Carry a saved row's version into the open panel's detail before the next queued patch runs. */ function syncDetailRowVersion( queryClient: QueryClient, id: string | number, row: WorkOrderTableRow, ) { queryClient.setQueryData(queryKeys.workOrders.boardDetail(id), (detail) => detail && row.rowVersion ? { ...detail, info: { ...detail.info, rowVersion: row.rowVersion, dispatchRowVersion: row.dispatchRowVersion || detail.info.dispatchRowVersion, }, } : detail, ); } type SavedRowSync = { queryClient: QueryClient; /** Last server row each patch returned, for panels whose detail query never cached data. */ savedRows: Map; }; function recordSavedRow( { queryClient, savedRows }: SavedRowSync, id: string | number, row: WorkOrderTableRow, ) { savedRows.set(String(id), row); syncDetailRowVersion(queryClient, id, row); } async function runPatchAndSyncDetail( sync: SavedRowSync, id: string | number, patch: WorkOrderTablePatch, readBase: (id: string | number) => WorkOrderTableRow | undefined, ): Promise { 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); } 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(), []); 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>) { 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", }; }