mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Introduced new yard distances resource with CRUD operations. - Created migration for yard distances table with necessary constraints. - Implemented service and repository for yard distances handling. - Added controller for API endpoints to manage yard distances. - Updated rule engine configuration to include yard distances. - Enhanced rule engine resource page to support yard distance selection. - Updated contracts and train builder pages to handle new yard distance logic. - Added error handling utility for better error message extraction.
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
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 {
|
|
LEGACY_APPROVAL_ROLES,
|
|
RULE_ENGINE_SELECT_NONE,
|
|
} from "@/pages/ruleEngine/config/resources";
|
|
import type {
|
|
RuleEngineRecord,
|
|
RuleEngineResourceSlug,
|
|
} from "@/types/rule-engine";
|
|
import {
|
|
invalidateRuleEngineList,
|
|
patchRuleEngineListRecord,
|
|
} from "@/utils/queryInvalidation";
|
|
import { extractErrorMessage } from "@/utils/errorExtractor";
|
|
|
|
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: (err) => toast.error(extractErrorMessage(err, "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),
|
|
});
|
|
|
|
/**
|
|
* Approval-step role options, sourced from the live IAM position types. The
|
|
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
|
* rule still stored against one of them renders its label instead of an empty
|
|
* select; a position type that reuses one of those values wins the dedupe.
|
|
*/
|
|
export const useApprovalRoleOptions = (enabled = true) =>
|
|
useQuery({
|
|
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("approval-rules", {
|
|
positionTypes: true,
|
|
}),
|
|
queryFn: () => ruleEngineService.getApprovalPositionTypes(),
|
|
enabled,
|
|
select: (rows): { label: string; value: string }[] => {
|
|
const byValue = new Map<string, { label: string; value: string }>();
|
|
for (const row of rows) {
|
|
const value = String(row?.value ?? "").trim();
|
|
if (!value) continue;
|
|
byValue.set(value, { label: String(row.label ?? "").trim() || value, value });
|
|
}
|
|
for (const legacy of LEGACY_APPROVAL_ROLES) {
|
|
if (!byValue.has(legacy.value)) byValue.set(legacy.value, legacy);
|
|
}
|
|
return [...byValue.values()];
|
|
},
|
|
});
|
|
|
|
/** 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<RuleEngineRecord>("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<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: (err) =>
|
|
toast.error(extractErrorMessage(err, "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: (err) =>
|
|
toast.error(extractErrorMessage(err, "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: (err) =>
|
|
toast.error(extractErrorMessage(err, "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: (err) => toast.error(extractErrorMessage(err, "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: (err) => toast.error(extractErrorMessage(err, "Failed to approve rate")),
|
|
});
|
|
|
|
return { submit, approve };
|
|
};
|