Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
2026-07-23 20:24:20 +00:00

970 lines
33 KiB
TypeScript

import { useAuth } from "@/auth/useAuth";
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 { 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,
useWagonTypeOptions,
useYardOptions,
type YardOption,
usePriorityRuleWorkflow,
useRateChangeWorkflow,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
useRuleEngineOrderList,
useRuleEngineOrderMutations,
} from "@/hooks/rule-engine/useRuleEngine";
import {
DEFAULT_CONFIGURATION_SLUG,
DEFAULT_RULES_SLUG,
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
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 }[] => {
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 are sold per direction +
// route, so their yard dropdowns narrow exactly like base freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].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 !== "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);
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";
const [rateError, setRateError] = useState<string | null>(null);
const rateChangeWorkflow = useRateChangeWorkflow(
Boolean(isRates && canView),
setRateError,
);
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);
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
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 [];
return config.formFields.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 ?? [],
};
}
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);
// 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.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="/dashboard/overview" 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") {
// 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";
payload = {
...values,
currency: "USD",
trigger: isSurcharge ? values.trigger : "ALWAYS",
};
// 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;
}
} 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={rateError != null}
onClose={() => setRateError(null)}
title="Cannot save rate change"
centered
>
<Text size="sm" c="red">
{rateError}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={() => setRateError(null)}>
Close
</Button>
</Group>
</Modal>
<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>
<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) ||
(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;