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 { 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 { 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, }: { id: string | number; patch: WorkOrderTablePatch; persistPatch: WorkOrderTablePatch; }) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, 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) => { const persistPatch = resolvePatch(id, patch); return patchMutation.mutateAsync({ id, patch, persistPatch }); }; 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", }; }