mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1137 lines
40 KiB
TypeScript
1137 lines
40 KiB
TypeScript
import { useAuth } from "@/auth/useAuth";
|
|
import { resolveLandingPath } from "@/lib/landing";
|
|
import {
|
|
canAccessRuleEngineResource,
|
|
canApproveRuleEngineChange,
|
|
} from "@/lib/permissions";
|
|
import type { ColumnDef } from "@edr/ui-common";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Group,
|
|
List,
|
|
Loader,
|
|
Modal,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { Clock, Plus } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Navigate, useLocation, useParams } from "react-router-dom";
|
|
|
|
import { PageContainer, PageHeader } from "@/components/page";
|
|
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
|
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
|
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
|
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
|
|
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
|
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
|
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
|
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
|
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
|
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
|
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
|
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
|
import {
|
|
useApprovalChain,
|
|
useApprovalRoleOptions,
|
|
useCargoLeafOptions,
|
|
useCargoTypeParentOptions,
|
|
useContainerTypeOptions,
|
|
useLiveRateOptions,
|
|
useShippingLineCompanyOptions,
|
|
useWagonTypeOptions,
|
|
useYardOptions,
|
|
type YardOption,
|
|
usePriorityRuleWorkflow,
|
|
useRateChangeWorkflow,
|
|
useRateWorkflow,
|
|
useRuleEngineList,
|
|
useRuleEngineMutations,
|
|
useRuleEngineOrderList,
|
|
useRuleEngineOrderMutations,
|
|
} from "@/hooks/rule-engine/useRuleEngine";
|
|
import {
|
|
DEFAULT_CONFIGURATION_SLUG,
|
|
DEFAULT_RULES_SLUG,
|
|
ROUTE_SCOPED_TRIGGERS,
|
|
RULE_ENGINE_CATEGORY_BASE_PATH,
|
|
RULE_ENGINE_SELECT_NONE,
|
|
getRuleEngineResource,
|
|
rateUnitOptions,
|
|
type RuleEngineNavCategory,
|
|
} from "@/pages/ruleEngine/config/resources";
|
|
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
|
import {
|
|
DataTable,
|
|
DataTableFooter,
|
|
usePagination,
|
|
} from "@edr/ui-common";
|
|
|
|
const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
|
|
const normalized = pathname.toLowerCase();
|
|
if (normalized.startsWith("/dashboard/configuration")) return "configuration";
|
|
if (normalized.startsWith("/dashboard/rules")) return "rules";
|
|
return undefined;
|
|
};
|
|
|
|
/**
|
|
* Which yards may sit at one end of the leg a base-freight rate prices.
|
|
*
|
|
* The railway sells three shapes and each pins the countries: an import lands
|
|
* at a Djibouti port and rails inland, an export is the reverse, and intercity
|
|
* stays inside Ethiopia. Narrowing the dropdown is what stops an import rate
|
|
* from being configured Ethiopia → Ethiopia — the API rejects that too, but
|
|
* the admin should never be offered it. Non-base-freight rates carry no leg,
|
|
* so they get nothing.
|
|
*/
|
|
const yardOptionsForLegEnd = (
|
|
yards: YardOption[],
|
|
values: Record<string, unknown>,
|
|
end: "origin" | "destination",
|
|
): { label: string; value: string }[] => {
|
|
// A shipping-line rate names its shape in its own fields and is always
|
|
// import; map it onto the appliesTo/direction pair the rest of this function
|
|
// reads so the country narrowing is shared rather than duplicated.
|
|
if (values.isShippingLineRate === true) {
|
|
if (!values.shippingLineCompanyId) return [];
|
|
const isBase = values.shippingLineRateKind === "BASE";
|
|
values = {
|
|
...values,
|
|
appliesTo: isBase
|
|
? String(values.shippingLineCargoKind ?? "")
|
|
: "OTHER",
|
|
tradeDirection: "IMPORT",
|
|
};
|
|
}
|
|
const appliesTo = String(values.appliesTo ?? "");
|
|
let country: string | undefined;
|
|
if (appliesTo === "INTERCITY") {
|
|
country = "Ethiopia";
|
|
} else if (
|
|
appliesTo === "CONTAINER" ||
|
|
appliesTo === "BULK" ||
|
|
// Customs clearance, empty-container return and fuel are sold per
|
|
// direction + route, so their yard dropdowns narrow exactly like base
|
|
// freight.
|
|
(appliesTo === "OTHER" &&
|
|
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
|
) {
|
|
const direction = String(values.tradeDirection ?? "");
|
|
// Direction is what decides the countries, so offer nothing until it is set
|
|
// rather than defaulting to one and letting it read as a real choice.
|
|
if (direction === "DOMESTIC") {
|
|
// A fuel rate's intercity lane — stays inside Ethiopia.
|
|
country = "Ethiopia";
|
|
} else {
|
|
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
|
|
const startsInEthiopia = direction === "EXPORT";
|
|
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
|
|
? "Ethiopia"
|
|
: "Djibouti";
|
|
}
|
|
}
|
|
if (!country) return [];
|
|
return yards
|
|
.filter((yard) => yard.country === country)
|
|
.map(({ label, value }) => ({ label, value }));
|
|
};
|
|
|
|
const RuleEngineResourcePage = () => {
|
|
const { user } = useAuth();
|
|
const { resource: resourceSlug } = useParams<{ resource: string }>();
|
|
const location = useLocation();
|
|
const category = pathCategory(location.pathname);
|
|
const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined;
|
|
|
|
const defaultPath = category
|
|
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
|
|
}`
|
|
: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`;
|
|
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
const [search, setSearch] = useState("");
|
|
// Category tabs (rates page): the active tab's filters go to the backend.
|
|
const [activeTab, setActiveTab] = useState<string>(
|
|
config?.listTabs?.[0]?.key ?? "",
|
|
);
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
|
|
null,
|
|
);
|
|
const [chainOpen, setChainOpen] = useState(false);
|
|
// Yards only: which desks work at this yard (input to yard access scoping).
|
|
const [desksYard, setDesksYard] = useState<Record<string, unknown> | null>(
|
|
null,
|
|
);
|
|
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
|
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
);
|
|
|
|
const canView = Boolean(
|
|
config && canAccessRuleEngineResource(user, config.slug, "view"),
|
|
);
|
|
// Per-action gates replace the retired coarse "manage": Add shows only with
|
|
// create, row Edit with update, row Delete with delete.
|
|
const canCreate = Boolean(
|
|
config && canAccessRuleEngineResource(user, config.slug, "create"),
|
|
);
|
|
const canUpdate = Boolean(
|
|
config && canAccessRuleEngineResource(user, config.slug, "update"),
|
|
);
|
|
const canDelete = Boolean(
|
|
config && canAccessRuleEngineResource(user, config.slug, "delete"),
|
|
);
|
|
// Update-class controls (reorder, rate submit/approve, approval-rule decide)
|
|
// all map to the update permission — the matching endpoints now require it.
|
|
const canUpdateControls = canUpdate;
|
|
|
|
const listParams = useMemo(
|
|
() => ({
|
|
search: config?.supportsSearch ? search.trim() || undefined : undefined,
|
|
page: pagination.pageIndex + 1,
|
|
pageSize: pagination.pageSize,
|
|
...(config?.orderConfig
|
|
? {
|
|
sortBy: config.orderConfig.field,
|
|
sortOrder: "ASC" as const,
|
|
}
|
|
: {}),
|
|
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
|
|
}),
|
|
[
|
|
config?.orderConfig,
|
|
config?.supportsSearch,
|
|
config?.listTabs,
|
|
activeTab,
|
|
search,
|
|
pagination.pageIndex,
|
|
pagination.pageSize,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
|
setSearch("");
|
|
setActiveTab(config?.listTabs?.[0]?.key ?? "");
|
|
}, [config?.slug, config?.listTabs, setPagination]);
|
|
|
|
const { data, isLoading, isError, error } = useRuleEngineList(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
listParams,
|
|
);
|
|
|
|
const { create, update, remove } = useRuleEngineMutations(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
);
|
|
const { reorder, moveOrder } = useRuleEngineOrderMutations(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
);
|
|
const { data: orderListData, isLoading: orderListLoading } =
|
|
useRuleEngineOrderList(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
Boolean(orderDialogOpen && config?.orderConfig),
|
|
config?.orderConfig?.field,
|
|
);
|
|
const { submit, approve } = useRateWorkflow();
|
|
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
|
|
chainOpen && config?.slug === "approval-rules",
|
|
);
|
|
|
|
// A LIVE rate is what pricing charges, so editing one files a change request
|
|
// instead of mutating: the rate keeps its current value until an approver
|
|
// applies the change. DRAFT rates still edit directly.
|
|
const isRates = config?.slug === "rates";
|
|
// No error modal here: the workflow falls back to a toast when no handler is
|
|
// passed, which keeps failures visible without a dialog to dismiss.
|
|
const rateChangeWorkflow = useRateChangeWorkflow(
|
|
Boolean(isRates && canView),
|
|
);
|
|
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
|
|
/** rateId → its pending change, for the row badge. */
|
|
const pendingByRateId = useMemo(() => {
|
|
const map = new Map<string, RateChangeRequest>();
|
|
for (const r of rateChangeWorkflow.pending.data ?? []) map.set(r.rateId, r);
|
|
return map;
|
|
}, [rateChangeWorkflow.pending.data]);
|
|
|
|
// Priority rules never mutate directly: changes are filed for approval and a
|
|
// pending queue renders above the table. Validation errors (range collision,
|
|
// gap, ceiling) surface in a modal so the text is impossible to miss.
|
|
const isPriorityRules = config?.slug === "priority-configs";
|
|
const [priorityError, setPriorityError] = useState<string | null>(null);
|
|
const priorityWorkflow = usePriorityRuleWorkflow(
|
|
Boolean(isPriorityRules && canView),
|
|
setPriorityError,
|
|
);
|
|
|
|
const editingId = editing?.id ? String(editing.id) : undefined;
|
|
const usesContainerTypeField = Boolean(
|
|
config?.formFields.some((f) => f.name === "containerTypeId"),
|
|
);
|
|
const usesCargoTypeField = Boolean(
|
|
config?.formFields.some((f) => f.name === "cargoTypeId"),
|
|
);
|
|
const usesLiveRateField = Boolean(
|
|
config?.formFields.some((f) => f.name === "rateId"),
|
|
);
|
|
const usesWagonTypeField = Boolean(
|
|
config?.formFields.some(
|
|
(f) => f.name === "wagonTypeId" || f.name === "wagonTypeIds",
|
|
),
|
|
);
|
|
|
|
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
|
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
|
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
|
|
useCargoLeafOptions(usesCargoTypeField);
|
|
// No "None" on rates: a rate's container scope is either a real type or the
|
|
// field is hidden entirely, so offering None only invites an unscoped rate.
|
|
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
|
useContainerTypeOptions(false, usesContainerTypeField);
|
|
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
|
useLiveRateOptions(usesLiveRateField);
|
|
const usesShippingLineField = Boolean(
|
|
config?.formFields.some((f) => f.name === "shippingLineCompanyId"),
|
|
);
|
|
const {
|
|
data: shippingLineOptions,
|
|
isLoading: shippingLineOptionsLoading,
|
|
} = useShippingLineCompanyOptions(usesShippingLineField);
|
|
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
|
useWagonTypeOptions(usesWagonTypeField);
|
|
const usesYardField = Boolean(
|
|
config?.formFields.some(
|
|
(f) =>
|
|
f.name === "originYardId" ||
|
|
f.name === "fromYardId" ||
|
|
f.name === "toYardId",
|
|
),
|
|
);
|
|
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
|
useYardOptions(usesYardField);
|
|
const yardLabelById = useMemo(
|
|
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
|
|
[yardOptions],
|
|
);
|
|
const usesApprovalRoleField = Boolean(
|
|
config?.formFields.some(
|
|
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
|
),
|
|
);
|
|
const { data: approvalRoleOptions, isLoading: approvalRoleOptionsLoading } =
|
|
useApprovalRoleOptions(usesApprovalRoleField);
|
|
|
|
// Full rule list backing the auto-filled "min wagon count": the next range
|
|
// always continues the chain for the selected type (per currency), so the
|
|
// form needs every existing rule, not the current page.
|
|
const { data: allPriorityRules } = useRuleEngineOrderList(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
Boolean(isPriorityRules && formOpen),
|
|
config?.orderConfig?.field,
|
|
);
|
|
|
|
const formFields = useMemo(() => {
|
|
if (!config) return [];
|
|
// Last-mile bands (both modes): creating uses the multi-row tier list (one
|
|
// rate per tier, each tier carrying its own rate value); editing an
|
|
// existing band row keeps the single From/To/value fields (a rate row IS
|
|
// one band).
|
|
const bandFields = config.formFields.filter((field) => {
|
|
if (config.slug !== "rates") return true;
|
|
if (field.type === "tierList") return !editing;
|
|
if (editing) return true;
|
|
// On create the tier rows carry From/To/value — drop the single fields,
|
|
// including the last-mile "Rate value" (the non-last-mile one keeps its
|
|
// own showIf).
|
|
if (
|
|
field.name === "rateValue" &&
|
|
field.showWhen?.field === "appliesTo" &&
|
|
field.showWhen.equals.includes("LAST_MILE")
|
|
) {
|
|
return false;
|
|
}
|
|
return field.name !== "minKm" && field.name !== "maxKm";
|
|
});
|
|
return bandFields.map((field) => {
|
|
if (isPriorityRules && field.name === "minWagonCount") {
|
|
return {
|
|
...field,
|
|
// Editing keeps the rule's own start (a lower gap never forces it to
|
|
// move); creating always continues the chain / fills the lowest gap.
|
|
computeValue: (values: Record<string, unknown>) =>
|
|
editing?.minWagonCount != null
|
|
? Number(editing.minWagonCount)
|
|
: nextPriorityRangeStart(
|
|
allPriorityRules ?? [],
|
|
String(values.type ?? ""),
|
|
!values.currency || values.currency === RULE_ENGINE_SELECT_NONE
|
|
? null
|
|
: String(values.currency),
|
|
editingId,
|
|
),
|
|
};
|
|
}
|
|
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
|
|
return {
|
|
...field,
|
|
options: cargoParentOptions ?? [
|
|
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
|
],
|
|
};
|
|
}
|
|
if (field.name === "containerTypeId") {
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
options: containerTypeOptions ?? [],
|
|
};
|
|
}
|
|
if (field.name === "cargoTypeId") {
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
options: cargoLeafOptions ?? [],
|
|
};
|
|
}
|
|
// Rate units follow the picked commodity: a per-item (break-bulk) cargo
|
|
// is priced per item where a weighed one is priced per ton.
|
|
if (field.name === "rateUnit" && field.optionsFromValues) {
|
|
return {
|
|
...field,
|
|
optionsFromValues: (values: Record<string, unknown>) =>
|
|
rateUnitOptions(
|
|
values,
|
|
(cargoLeafOptions ?? []).find(
|
|
(o) => o.value === String(values.cargoTypeId ?? ""),
|
|
)?.unitOfMeasure ?? "",
|
|
),
|
|
};
|
|
}
|
|
if (field.name === "shippingLineCompanyId") {
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
options: shippingLineOptions ?? [],
|
|
};
|
|
}
|
|
if (field.name === "rateId") {
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
options: liveRateOptions ?? [],
|
|
};
|
|
}
|
|
if (field.name === "wagonTypeId") {
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
options: wagonTypeOptions ?? [],
|
|
};
|
|
}
|
|
if (field.name === "wagonTypeIds") {
|
|
return {
|
|
...field,
|
|
type: "multiselect" as const,
|
|
options: wagonTypeOptions ?? [],
|
|
};
|
|
}
|
|
// Approval steps are configured against live IAM position types; until
|
|
// they load, the static legacy list on the field config stands in so an
|
|
// existing row's role still shows a label.
|
|
if (field.name === "requiredRole" || field.name === "blocksRole") {
|
|
if (!approvalRoleOptions) return field;
|
|
const includeNone = field.name === "blocksRole";
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
options: includeNone
|
|
? [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...approvalRoleOptions]
|
|
: approvalRoleOptions,
|
|
};
|
|
}
|
|
// Each end of the leg only offers yards in the country that end of the
|
|
// trade actually sits in, so an import can't be configured as if it
|
|
// started inland. Resolved per keystroke because the legal set changes
|
|
// with the direction the admin picks.
|
|
// Yard-distance endpoints have no country restriction — any yard can pair
|
|
// with any other; the other end is just excluded so A↔A can't be entered.
|
|
if (field.name === "fromYardId" || field.name === "toYardId") {
|
|
const otherEnd = field.name === "fromYardId" ? "toYardId" : "fromYardId";
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
optionsFromValues: (values: Record<string, unknown>) =>
|
|
(yardOptions ?? [])
|
|
.filter(({ value }) => value !== String(values[otherEnd] ?? ""))
|
|
.map(({ label, value }) => ({ label, value })),
|
|
};
|
|
}
|
|
if (field.name === "originYardId" || field.name === "destinationYardId") {
|
|
const end = field.name === "originYardId" ? "origin" : "destination";
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
optionsFromValues: (values: Record<string, unknown>) =>
|
|
yardOptionsForLegEnd(yardOptions ?? [], values, end),
|
|
};
|
|
}
|
|
return field;
|
|
});
|
|
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, approvalRoleOptions, isPriorityRules, allPriorityRules, editing, editingId]);
|
|
|
|
const rows = data?.items ?? [];
|
|
const meta = data?.meta;
|
|
const pageCount = meta?.totalPages ?? 1;
|
|
const totalCount = meta?.total ?? rows.length;
|
|
|
|
const { data: createPositionList, isLoading: createPositionLoading } =
|
|
useRuleEngineOrderList(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
Boolean(formOpen && !editing && config?.orderConfig),
|
|
config?.orderConfig?.field,
|
|
);
|
|
|
|
const createPositionOptions = useMemo(() => {
|
|
if (!config?.orderConfig || !createPositionList?.length)
|
|
return undefined;
|
|
return createPositionList
|
|
.filter((row) => row.id)
|
|
.map((row) => ({
|
|
label: getOrderItemLabel(row, config.slug),
|
|
value: String(row.id),
|
|
}));
|
|
}, [config?.orderConfig, config?.slug, createPositionList]);
|
|
|
|
const handleApproveRate = useCallback(
|
|
(record: RuleEngineRecord) => {
|
|
approve.mutate(String(record.id));
|
|
},
|
|
[approve],
|
|
);
|
|
|
|
const handleMoveOrder = useCallback(
|
|
(id: string, direction: "up" | "down") => {
|
|
moveOrder.mutate({ id, direction });
|
|
},
|
|
[moveOrder],
|
|
);
|
|
|
|
const columns = useMemo((): ColumnDef<RuleEngineRecord>[] => {
|
|
if (!config) return [];
|
|
|
|
const headerClassName = ruleEngineTable.headerCell;
|
|
const cellClassName = ruleEngineTable.bodyCell;
|
|
|
|
const base: ColumnDef<RuleEngineRecord>[] = config.columns.map((col) => ({
|
|
id: col.id,
|
|
header: col.header,
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => {
|
|
const cell = formatCell(row.original[col.accessorKey], col.format, row.original);
|
|
// On the rate column, show the proposed value under the live one — the
|
|
// live value stays the headline because it is what still gets charged.
|
|
if (!isRates || col.accessorKey !== "rateValue") return cell;
|
|
const change = pendingByRateId.get(String(row.original.id));
|
|
if (!change || change.payload.rateValue === undefined) return cell;
|
|
return (
|
|
<Stack gap={0}>
|
|
{cell}
|
|
<Tooltip label="Awaiting approval — this rate still charges its current value">
|
|
<Group gap={4} wrap="nowrap">
|
|
<Clock size={11} color="var(--mantine-color-orange-6)" />
|
|
<Text size="xs" c="orange.7" fw={600}>
|
|
{Number(change.payload.rateValue).toLocaleString()} pending
|
|
</Text>
|
|
</Group>
|
|
</Tooltip>
|
|
</Stack>
|
|
);
|
|
},
|
|
}));
|
|
|
|
base.push({
|
|
id: "actions",
|
|
header: "Actions",
|
|
size: config.orderConfig ? 200 : 140,
|
|
minSize: config.orderConfig ? 180 : 120,
|
|
meta: {
|
|
headerClassName,
|
|
cellClassName: `${cellClassName} whitespace-nowrap`,
|
|
},
|
|
cell: ({ row }) => (
|
|
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
|
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
|
{config.slug === "yards" ? (
|
|
<Tooltip label="Desks that work at this yard">
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
onClick={() => setDesksYard(row.original)}
|
|
>
|
|
Desks
|
|
</Button>
|
|
</Tooltip>
|
|
) : null}
|
|
{config.orderConfig && canUpdateControls ? (
|
|
<RuleEngineOrderControls
|
|
record={row.original}
|
|
orderConfig={config.orderConfig}
|
|
totalCount={totalCount}
|
|
disabled={moveOrder.isPending}
|
|
onMove={handleMoveOrder}
|
|
/>
|
|
) : null}
|
|
<RuleEngineRecordActions
|
|
record={row.original}
|
|
config={config}
|
|
layout="row"
|
|
readOnly={!canUpdateControls}
|
|
onEdit={(record) => {
|
|
setEditing(record);
|
|
setFormOpen(true);
|
|
}}
|
|
onDelete={setDeleteTarget}
|
|
onViewChain={
|
|
config.slug === "approval-rules"
|
|
? () => setChainOpen(true)
|
|
: undefined
|
|
}
|
|
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
|
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
|
/>
|
|
</Group>
|
|
</div>
|
|
),
|
|
});
|
|
|
|
return base;
|
|
}, [
|
|
canUpdateControls,
|
|
config,
|
|
isRates,
|
|
pendingByRateId,
|
|
submit,
|
|
handleApproveRate,
|
|
handleMoveOrder,
|
|
moveOrder.isPending,
|
|
totalCount,
|
|
]);
|
|
|
|
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
|
|
|
if (!resourceSlug || !category) {
|
|
return <Navigate to={defaultPath} replace />;
|
|
}
|
|
|
|
if (!config || config.category !== category) {
|
|
return <Navigate to={defaultPath} replace />;
|
|
}
|
|
|
|
if (!canView) {
|
|
return <Navigate to={resolveLandingPath(user)} replace />;
|
|
}
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const openEdit = (record: RuleEngineRecord) => {
|
|
setEditing(record);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const handleFormSubmit = (values: Record<string, unknown>) => {
|
|
let payload = values;
|
|
if (config.slug === "rates" && values.isShippingLineRate === true) {
|
|
// A shipping-line rate asks its shape as "base freight vs surcharge" +
|
|
// "container vs bulk"; the API takes the same appliesTo/trigger pair as a
|
|
// customer rate, so translate here and drop the form-only fields. Always
|
|
// import (the only direction a line ships) and always USD.
|
|
const {
|
|
isShippingLineRate: _toggle,
|
|
shippingLineRateKind,
|
|
shippingLineCargoKind,
|
|
...rest
|
|
} = values;
|
|
void _toggle;
|
|
const isBase = shippingLineRateKind === "BASE";
|
|
payload = {
|
|
...rest,
|
|
appliesTo: isBase ? String(shippingLineCargoKind ?? "CONTAINER") : "OTHER",
|
|
trigger: isBase ? "ALWAYS" : values.trigger,
|
|
tradeDirection: "IMPORT",
|
|
currency: "USD",
|
|
};
|
|
if (editing?.id && editing.status === "LIVE") {
|
|
rateChangeWorkflow.submit.mutate(
|
|
{ rateId: String(editing.id), update: payload },
|
|
{
|
|
onSuccess: () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
},
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
} else if (config.slug === "rates") {
|
|
// Base-freight categories have no surcharge trigger field — the engine
|
|
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
|
// chosen trigger.
|
|
const isSurcharge = values.appliesTo === "OTHER";
|
|
// Last mile: the form's calculation mode picks the unit (bulk = per
|
|
// ton·km, container = per km + distance band) and the currency stays as
|
|
// chosen (birr or dollar). Everything else remains USD-only.
|
|
const isLastMile = values.appliesTo === "LAST_MILE";
|
|
// The shipping-line toggle is form-only — the API's whitelist rejects the
|
|
// whole payload if it leaks through ("property isShippingLineRate should
|
|
// not exist").
|
|
const { lastMileMode, isShippingLineRate: _toggle, ...rest } = values;
|
|
void _toggle;
|
|
payload = {
|
|
...rest,
|
|
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
|
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
|
...(isLastMile
|
|
? {
|
|
// Empty "To km" means an open-ended band — send null so an
|
|
// edit can clear a previously-set ceiling. On create the tier
|
|
// spread below overrides the band fields per tier.
|
|
maxKm: values.maxKm ?? null,
|
|
...(lastMileMode === "BULK"
|
|
? { rateUnit: "PER_TON_KM", containerTypeId: undefined }
|
|
: { rateUnit: "PER_KM" }),
|
|
}
|
|
: {}),
|
|
};
|
|
// Editing a LIVE rate files a change request — the rate keeps charging
|
|
// its current value until an approver applies it. DRAFT rates fall
|
|
// through to the normal update below.
|
|
if (editing?.id && editing.status === "LIVE") {
|
|
rateChangeWorkflow.submit.mutate(
|
|
{ rateId: String(editing.id), update: payload },
|
|
{
|
|
onSuccess: () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
},
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
// Container-mode create: the tier list becomes one rate row per tier,
|
|
// created sequentially so an overlap/duplicate rejection stops the batch
|
|
// with its own toast instead of half-failing in parallel.
|
|
const tiers = (
|
|
payload as {
|
|
tiers?: Array<{ minKm: number; maxKm: number | null; rateValue: number }>;
|
|
}
|
|
).tiers;
|
|
if (!editing?.id && Array.isArray(tiers)) {
|
|
const { tiers: _omitted, ...base } = payload as Record<string, unknown>;
|
|
void _omitted;
|
|
void (async () => {
|
|
try {
|
|
for (const tier of tiers) {
|
|
await create.mutateAsync({ ...base, ...tier });
|
|
}
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
} catch {
|
|
// The create mutation already toasted the failure; keep the dialog
|
|
// open so the admin can fix the tier set and retry.
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
} else if (isPriorityRules) {
|
|
// Label is required by the backend but hidden in the UI for now.
|
|
payload = { ...values, label: String(Date.now()) };
|
|
// Approval workflow: file a change request instead of mutating directly.
|
|
// On update, keep the target's existing label rather than a fresh stamp.
|
|
if (editing?.id) {
|
|
priorityWorkflow.submit.mutate(
|
|
{
|
|
action: "UPDATE",
|
|
priorityConfigId: String(editing.id),
|
|
update: { ...values, label: String(editing.label ?? Date.now()) },
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
},
|
|
},
|
|
);
|
|
} else {
|
|
priorityWorkflow.submit.mutate(
|
|
{ action: "CREATE", create: payload },
|
|
{
|
|
onSuccess: () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
return;
|
|
} else if (config.slug === "weight-limit-rules") {
|
|
// Empty max capacity means "no ceiling" — send null explicitly so an
|
|
// edit can clear a previously-set ceiling (omitting the key keeps it).
|
|
payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null };
|
|
}
|
|
|
|
if (editing?.id) {
|
|
update.mutate(
|
|
{ id: editing.id, payload },
|
|
{
|
|
onSuccess: () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
},
|
|
},
|
|
);
|
|
} else {
|
|
create.mutate(payload, {
|
|
onSuccess: () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const itemLabel = config.label.toLowerCase();
|
|
const addLabel = `Add ${config.label.replace(/s$/, "")}`;
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title={config.label}
|
|
subtitle={config.subtitle}
|
|
action={
|
|
canCreate && config.slug !== "container-types" ? (
|
|
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
|
{addLabel}
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
{isPriorityRules ? (
|
|
<PriorityRuleApprovalsSection
|
|
requests={priorityWorkflow.pending.data ?? []}
|
|
canDecide={canUpdateControls}
|
|
approve={priorityWorkflow.approve}
|
|
reject={priorityWorkflow.reject}
|
|
/>
|
|
) : null}
|
|
|
|
{isRates ? (
|
|
<RateApprovalsSection
|
|
requests={rateChangeWorkflow.pending.data ?? []}
|
|
canDecide={canApproveRates}
|
|
approve={rateChangeWorkflow.approve}
|
|
reject={rateChangeWorkflow.reject}
|
|
yardLabels={yardLabelById}
|
|
/>
|
|
) : null}
|
|
|
|
<Modal
|
|
opened={priorityError != null}
|
|
onClose={() => setPriorityError(null)}
|
|
title="Cannot save priority rule"
|
|
centered
|
|
size="md"
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="red.8">
|
|
{priorityError}
|
|
</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setPriorityError(null)}>
|
|
OK
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Card p={0}>
|
|
<Stack gap={0}>
|
|
{config.listTabs && (
|
|
<Tabs
|
|
value={activeTab}
|
|
onChange={(v) => {
|
|
setActiveTab(v ?? config.listTabs![0].key);
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}}
|
|
px="md"
|
|
pt="sm"
|
|
>
|
|
<Tabs.List>
|
|
{config.listTabs.map((tab) => (
|
|
<Tabs.Tab key={tab.key} value={tab.key}>
|
|
{tab.label}
|
|
</Tabs.Tab>
|
|
))}
|
|
</Tabs.List>
|
|
</Tabs>
|
|
)}
|
|
<Box px="md" pt="md" pb="sm" w="100%">
|
|
<RuleEngineToolbar
|
|
search={search}
|
|
onSearchChange={
|
|
config.supportsSearch
|
|
? (v) => {
|
|
setSearch(v);
|
|
setPagination({
|
|
pageIndex: 0,
|
|
pageSize: pagination.pageSize,
|
|
});
|
|
}
|
|
: undefined
|
|
}
|
|
showSearch={Boolean(config.supportsSearch)}
|
|
searchPlaceholder={config.searchPlaceholder}
|
|
onManageOrder={
|
|
canUpdateControls && config.orderConfig
|
|
? () => setOrderDialogOpen(true)
|
|
: undefined
|
|
}
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
/>
|
|
</Box>
|
|
|
|
{viewMode === "table" ? (
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
status={tableStatus}
|
|
error={
|
|
isError
|
|
? {
|
|
message: "Failed to load data",
|
|
description:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Unknown error",
|
|
}
|
|
: undefined
|
|
}
|
|
emptyMessage={`No ${itemLabel} found.`}
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount,
|
|
}}
|
|
tableOptions={{
|
|
manualPagination: true,
|
|
pageCount,
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
}}
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
footer={({ table, pagination: footerPagination }) => (
|
|
<DataTableFooter
|
|
table={table}
|
|
pagination={footerPagination}
|
|
options={{
|
|
labels: {
|
|
showing: "Showing",
|
|
ofLabel: "of",
|
|
items: itemLabel,
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
) : (
|
|
<RuleEngineCardGrid
|
|
config={config}
|
|
rows={rows}
|
|
status={tableStatus}
|
|
emptyMessage={`No ${itemLabel} found.`}
|
|
itemLabel={itemLabel}
|
|
pagination={pagination}
|
|
pageCount={pageCount}
|
|
totalCount={totalCount}
|
|
onPaginationChange={setPagination}
|
|
readOnly={!canUpdate && !canDelete}
|
|
onEdit={canUpdate ? openEdit : undefined}
|
|
onDelete={canDelete ? setDeleteTarget : undefined}
|
|
onViewChain={
|
|
config.slug === "approval-rules"
|
|
? () => setChainOpen(true)
|
|
: undefined
|
|
}
|
|
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
|
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
|
|
<YardDesksModal
|
|
opened={!!desksYard}
|
|
onClose={() => setDesksYard(null)}
|
|
readOnly={!canUpdateControls}
|
|
yard={
|
|
desksYard
|
|
? {
|
|
id: String(desksYard.id),
|
|
code: String(desksYard.code ?? ""),
|
|
label: String(desksYard.label ?? ""),
|
|
}
|
|
: null
|
|
}
|
|
/>
|
|
|
|
<RuleEngineFormDialog
|
|
open={formOpen}
|
|
onOpenChange={setFormOpen}
|
|
title={
|
|
editing
|
|
? `Edit ${config.label.replace(/s$/, "")}`
|
|
: `Add ${config.label.replace(/s$/, "")}`
|
|
}
|
|
description={
|
|
editing
|
|
? `Update this ${config.label.toLowerCase()} record.`
|
|
: `Create a new ${config.label.toLowerCase()} record.`
|
|
}
|
|
fields={formFields}
|
|
initialRecord={editing}
|
|
isSubmitting={
|
|
create.isPending || update.isPending || priorityWorkflow.submit.isPending
|
|
}
|
|
selectOptionsLoading={
|
|
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
|
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
|
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
|
(usesLiveRateField && liveRateOptionsLoading) ||
|
|
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
|
(usesYardField && yardOptionsLoading) ||
|
|
(usesShippingLineField && shippingLineOptionsLoading) ||
|
|
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
|
}
|
|
positionOptions={!editing ? createPositionOptions : undefined}
|
|
positionLoading={createPositionLoading}
|
|
onSubmit={handleFormSubmit}
|
|
/>
|
|
|
|
{config.orderConfig ? (
|
|
<ManageRuleEngineOrderDialog
|
|
open={orderDialogOpen}
|
|
onOpenChange={setOrderDialogOpen}
|
|
config={config}
|
|
items={orderListData ?? []}
|
|
isLoading={orderListLoading}
|
|
isSaving={reorder.isPending}
|
|
onSave={(payload) => {
|
|
reorder.mutate(payload, {
|
|
onSuccess: () => setOrderDialogOpen(false),
|
|
});
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
<Modal
|
|
opened={Boolean(deleteTarget)}
|
|
onClose={() => setDeleteTarget(null)}
|
|
title="Delete record?"
|
|
centered
|
|
size="sm"
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
{isPriorityRules
|
|
? "This files a delete request for approval — the rule is removed once an approver confirms."
|
|
: `This will soft-delete the selected ${config.label.toLowerCase()} record.`}
|
|
</Text>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="red"
|
|
loading={remove.isPending || priorityWorkflow.submit.isPending}
|
|
onClick={() => {
|
|
if (!deleteTarget) return;
|
|
if (isPriorityRules) {
|
|
priorityWorkflow.submit.mutate(
|
|
{
|
|
action: "DELETE",
|
|
priorityConfigId: String(deleteTarget.id),
|
|
},
|
|
{ onSuccess: () => setDeleteTarget(null) },
|
|
);
|
|
return;
|
|
}
|
|
remove.mutate(deleteTarget.id, {
|
|
onSuccess: () => setDeleteTarget(null),
|
|
});
|
|
}}
|
|
>
|
|
{isPriorityRules ? "Request delete" : "Delete"}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={chainOpen}
|
|
onClose={() => setChainOpen(false)}
|
|
title="Approval chain"
|
|
centered
|
|
size="md"
|
|
>
|
|
<Stack gap="md">
|
|
{chainLoading ? (
|
|
<Group justify="center" p="xl">
|
|
<Loader />
|
|
</Group>
|
|
) : (
|
|
<>
|
|
{(chainData ?? []).length === 0 ? (
|
|
<Text size="sm" c="dimmed">
|
|
No approval rules configured.
|
|
</Text>
|
|
) : (
|
|
<List spacing="md">
|
|
{(chainData ?? []).map((step, index) => (
|
|
<List.Item key={String(step.id ?? index)}>
|
|
<Stack gap="xs">
|
|
<Text size="sm" fw={500}>
|
|
Step {String(step.stepOrder ?? index + 1)}:{" "}
|
|
{String(step.actionLabel ?? "")}
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Role: {String(step.requiredRole ?? "—")}
|
|
</Text>
|
|
</Stack>
|
|
</List.Item>
|
|
))}
|
|
</List>
|
|
)}
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</Modal>
|
|
</PageContainer>
|
|
);
|
|
};
|
|
|
|
export default RuleEngineResourcePage;
|