import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { api } from "@/services/api"; import { ruleEngineService, type RuleEngineListParams, type SubmitPriorityRuleChangePayload, type SubmitRateChangePayload, } from "@/services/ruleEngine/ruleEngine.service"; import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources"; import type { RuleEngineRecord, RuleEngineResourceSlug, } from "@/types/rule-engine"; import { invalidateRuleEngineList, patchRuleEngineListRecord, } from "@/utils/queryInvalidation"; export const useRuleEngineList = ( resource: RuleEngineResourceSlug, params: RuleEngineListParams, ) => useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource, params), queryFn: () => ruleEngineService.list(resource, params), }); /** Full (page-walked) list used by the reorder dialog and create-position picker. */ export const useRuleEngineOrderList = ( resource: RuleEngineResourceSlug, enabled: boolean, sortBy?: string, ) => useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource), queryFn: () => ruleEngineService.listAll(resource, { sortBy, sortOrder: "ASC", }), enabled, }); export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) => { const qc = useQueryClient(); const reorder = useMutation({ mutationFn: (payload: { ids: string[]; requiresDirectorApproval?: boolean }) => ruleEngineService.reorder(resource, payload), onSuccess: async () => { toast.success("Order updated"); await invalidateRuleEngineList(qc, resource); await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) }); }, onError: () => toast.error("Failed to update order"), }); const moveOrder = useMutation({ mutationFn: ({ id, direction }: { id: string; direction: "up" | "down" }) => ruleEngineService.moveOrder(resource, id, direction), onSuccess: async () => { await invalidateRuleEngineList(qc, resource); await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) }); }, onError: () => toast.error("Cannot move item further in that direction"), }); return { reorder, moveOrder }; }; export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"), queryFn: () => ruleEngineService.listAll("cargo-types"), enabled, select: (rows) => { const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE }; const parents = rows .filter((row) => row.id && String(row.id) !== excludeId) .map((row) => { const name = String(row.cargoTypeName ?? "").trim(); const code = String(row.code ?? "").trim(); const label = name && code ? `${name} (${code})` : name || code || String(row.id); return { label, value: String(row.id) }; }); return [noneOption, ...parents]; }, }); /** * Cargo-type options restricted to LEAF nodes (actual commodities, not parent * groups). A node is a leaf when no other cargo type names it as parent. Used * by the Rate form's "Bulk cargo type" picker. */ export const useCargoLeafOptions = (enabled = true) => useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types", { leafOnly: true }), queryFn: () => ruleEngineService.listAll("cargo-types"), enabled, select: (rows) => { const parentIds = new Set( rows .map((row) => row.parentGroupId) .filter((id): id is string => Boolean(id)) .map((id) => String(id)), ); return rows .filter((row) => row.id && !parentIds.has(String(row.id))) .map((row) => { const name = String(row.cargoTypeName ?? "").trim(); const code = String(row.code ?? "").trim(); const label = name && code ? `${name} (${code})` : name || code || String(row.id); return { label, value: String(row.id) }; }); }, }); export function buildContainerTypeSelectOptions( rows: RuleEngineRecord[], includeNone: boolean, ): { label: string; value: string }[] { const options = rows .filter((row) => row.id) .map((row) => { const label = String(row.label ?? "").trim(); const code = String(row.code ?? "").trim(); const size = row.sizeFt ? `${String(row.sizeFt)}ft` : ""; const parts = [label || code || String(row.id), size].filter(Boolean); return { label: parts.join(" - "), value: String(row.id), }; }); if (!includeNone) return options; return [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...options]; } export const useContainerTypeOptions = ( includeNone = true, enabled = true, ) => useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', { includeNone, }), queryFn: () => ruleEngineService.listAll("container-types"), enabled, select: (rows) => buildContainerTypeSelectOptions(rows, includeNone), }); /** A yard option that remembers its country, so callers can filter by leg. */ export interface YardOption { label: string; value: string; country: string; } /** * Active yards for the rate form's origin/destination pickers. The country * rides along on each option because which yards are legal depends on the * rate's direction (import starts in Djibouti, export starts in Ethiopia). */ export const useYardOptions = (enabled = true) => useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("yards", { activeOnly: true }), queryFn: () => ruleEngineService.listAll("yards"), enabled, select: (rows): YardOption[] => rows .filter((row) => row.id && row.isActive !== false) .map((row) => { const label = String(row.label ?? "").trim(); const code = String(row.code ?? "").trim(); return { label: label && code ? `${label} (${code})` : label || code || String(row.id), value: String(row.id), country: String(row.country ?? ""), }; }), }); /** * Active wagon-type options for the cargo-type / container-type "Wagon type" * picker. The FK the selection sets drives train-scheduling wagon resolution. */ export const useWagonTypeOptions = (enabled = true) => useQuery({ ...api.wagonTypes.list.queryOptions(), enabled, select: (rows: { id: string; code: string; name: string; isActive?: boolean }[]) => rows .filter((wt) => wt.isActive !== false) .map((wt) => ({ label: wt.name ? `${wt.name} (${wt.code})` : wt.code, value: wt.id, })), }); export const useLiveRateOptions = (enabled = true) => useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("rates", { status: "LIVE" }), queryFn: () => ruleEngineService.listAll("rates", { status: "LIVE" }), enabled, select: (rows) => rows .filter((row) => row.id) .map((row) => { const rateType = String(row.rateType ?? "").replace(/_/g, " "); const currency = String(row.currency ?? ""); const value = row.rateValue != null ? String(row.rateValue) : ""; const unit = row.rateUnit ? String(row.rateUnit).replace(/_/g, " ") : ""; const parts = [rateType, currency, value, unit].filter(Boolean); return { label: parts.join(" · "), value: String(row.id), }; }), }); export const useApprovalChain = (enabled: boolean) => useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.chain, queryFn: () => ruleEngineService.getApprovalChain(), enabled, }); export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { const qc = useQueryClient(); const create = useMutation({ mutationFn: (payload: Record) => api.ruleEngine.create.call({ resource, payload }), onSuccess: async (created) => { toast.success("Created successfully"); patchRuleEngineListRecord(qc, resource, created); await invalidateRuleEngineList(qc, resource); }, onError: () => toast.error("Failed to create record"), }); const update = useMutation({ mutationFn: ({ id, payload, }: { id: string; payload: Record; }) => api.ruleEngine.update.call({ resource, id, payload }), onSuccess: async (updated) => { toast.success("Updated successfully"); patchRuleEngineListRecord(qc, resource, updated); await invalidateRuleEngineList(qc, resource); }, onError: () => toast.error("Failed to update record"), }); const remove = useMutation({ mutationFn: (id: string) => api.ruleEngine.remove.call({ resource, id }), onSuccess: async () => { toast.success("Deleted successfully"); await invalidateRuleEngineList(qc, resource); }, onError: () => toast.error("Failed to delete record"), }); return { create, update, remove }; }; /** * Priority-rule approval workflow. Every create/update/delete of a priority * config is SUBMITTED as a change request; an approver applies or rejects it. * Backend messages (range collision, gap, ceiling) surface verbatim — through * `onErrorMessage` (the page shows them in a modal) or a toast as fallback. */ export const usePriorityRuleWorkflow = ( enabled: boolean, onErrorMessage?: (message: string) => void, ) => { const qc = useQueryClient(); const backendMessage = (err: unknown, fallback: string) => { const msg = (err as { response?: { data?: { message?: string | string[] } } }) ?.response?.data?.message; if (Array.isArray(msg)) return msg.join(", "); return msg || fallback; }; const showError = (err: unknown, fallback: string) => { const message = backendMessage(err, fallback); if (onErrorMessage) onErrorMessage(message); else toast.error(message); }; const pending = useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges, queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"), enabled, }); const invalidate = async () => { await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges, }); await invalidateRuleEngineList(qc, "priority-configs"); // The full order-list backs the auto-filled min field — keep it fresh too. await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList("priority-configs"), }); }; const submit = useMutation({ mutationFn: (payload: SubmitPriorityRuleChangePayload) => ruleEngineService.submitPriorityRuleChange(payload), onSuccess: async () => { toast.success("Change submitted for approval — the team has been notified"); await invalidate(); }, onError: (err) => showError(err, "Failed to submit change"), }); const approve = useMutation({ mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => ruleEngineService.approvePriorityRuleChange(id, decisionNote), onSuccess: async () => { toast.success("Change approved and applied"); await invalidate(); }, onError: (err) => showError(err, "Failed to approve change"), }); const reject = useMutation({ mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => ruleEngineService.rejectPriorityRuleChange(id, decisionNote), onSuccess: async () => { toast.success("Change rejected"); await invalidate(); }, onError: (err) => showError(err, "Failed to reject change"), }); return { pending, submit, approve, reject }; }; /** * Approval workflow for edits to LIVE rates. The live rate keeps its current * value until a change is approved, so the rates list is invalidated on every * outcome — including reject, which restores the row's "no pending" state. */ export const useRateChangeWorkflow = ( enabled: boolean, onErrorMessage?: (message: string) => void, ) => { const qc = useQueryClient(); const showError = (err: unknown, fallback: string) => { const raw = (err as { response?: { data?: { message?: string | string[] } } }) ?.response?.data?.message; const message = (Array.isArray(raw) ? raw.join(", ") : raw) || fallback; if (onErrorMessage) onErrorMessage(message); else toast.error(message); }; const pending = useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges, queryFn: () => ruleEngineService.listRateChanges("PENDING"), enabled, }); const invalidate = async () => { await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges }); await invalidateRuleEngineList(qc, "rates"); }; const submit = useMutation({ mutationFn: (payload: SubmitRateChangePayload) => ruleEngineService.submitRateChange(payload), onSuccess: async () => { toast.success( "Change submitted for approval — the rate keeps its current value until approved", ); await invalidate(); }, onError: (err) => showError(err, "Failed to submit rate change"), }); const approve = useMutation({ mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => ruleEngineService.approveRateChange(id, decisionNote), onSuccess: async () => { toast.success("Rate change approved — the new rate is now live"); await invalidate(); }, onError: (err) => showError(err, "Failed to approve rate change"), }); const reject = useMutation({ mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => ruleEngineService.rejectRateChange(id, decisionNote), onSuccess: async () => { toast.success("Rate change rejected — the rate keeps its current value"); await invalidate(); }, onError: (err) => showError(err, "Failed to reject rate change"), }); return { pending, submit, approve, reject }; }; export const useRateWorkflow = () => { const qc = useQueryClient(); const submit = useMutation({ mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }), onSuccess: async (updated) => { toast.success("Rate submitted for approval"); patchRuleEngineListRecord(qc, "rates", updated); await invalidateRuleEngineList(qc, "rates"); }, onError: () => toast.error("Failed to submit rate"), }); const approve = useMutation({ mutationFn: (id: string) => api.ruleEngine.approveRate.call({ id }), onSuccess: async (updated) => { toast.success("Rate approved"); patchRuleEngineListRecord(qc, "rates", updated); await invalidateRuleEngineList(qc, "rates"); }, onError: () => toast.error("Failed to approve rate"), }); return { submit, approve }; };