Files
edr-platform/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
2026-07-15 13:29:01 +00:00

333 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
} 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<RuleEngineRecord>("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<RuleEngineRecord>("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<RuleEngineRecord>("container-types"),
enabled,
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
});
/**
* 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<RuleEngineRecord>("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<string, unknown>) =>
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<string, unknown>;
}) => 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.
* Error toasts surface the backend message so range-collision rejections
* ("15 overlaps existing rule …") reach the user verbatim.
*/
export const usePriorityRuleWorkflow = (enabled: boolean) => {
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 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");
};
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) => toast.error(backendMessage(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) => toast.error(backendMessage(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) => toast.error(backendMessage(err, "Failed to reject 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 };
};