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

604 lines
19 KiB
TypeScript

import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
Button,
Card,
Group,
List,
Loader,
Modal,
Stack,
Text,
} from "@mantine/core";
import { 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 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,
useCargoLeafOptions,
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
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 { 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;
};
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("");
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"),
);
const canManage = Boolean(
config && canAccessRuleEngineResource(user, config.slug, "manage"),
);
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?.orderConfig,
config?.supportsSearch,
search,
pagination.pageIndex,
pagination.pageSize,
],
);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
}, [config?.slug, 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",
);
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 { 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 formFields = useMemo(() => {
if (!config) return [];
return config.formFields.map((field) => {
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 ?? [],
};
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
const rows = data?.data ?? [];
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?.data?.length)
return undefined;
return createPositionList.data
.filter((row) => row.id)
.map((row) => ({
label: getOrderItemLabel(row, config.slug),
value: String(row.id),
}));
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
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 }) => formatCell(row.original[col.accessorKey], col.format),
}));
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 && canManage ? (
<RuleEngineOrderControls
record={row.original}
orderConfig={config.orderConfig}
totalCount={totalCount}
disabled={moveOrder.isPending}
onMove={handleMoveOrder}
/>
) : null}
<RuleEngineRecordActions
record={row.original}
config={config}
layout="row"
readOnly={!canManage}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
/>
</Group>
</div>
),
});
return base;
}, [
canManage,
config,
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",
};
} else if (config.slug === "priority-configs") {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
} 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={
canManage ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
{addLabel}
</Button>
) : undefined
}
/>
<Card p={0}>
<Stack gap={0}>
<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={
canManage && 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={!canManage}
onEdit={canManage ? openEdit : undefined}
onDelete={canManage ? setDeleteTarget : undefined}
onViewChain={
config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? 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}
selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}
onSubmit={handleFormSubmit}
/>
{config.orderConfig ? (
<ManageRuleEngineOrderDialog
open={orderDialogOpen}
onOpenChange={setOrderDialogOpen}
config={config}
items={orderListData?.data ?? []}
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">
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}
onClick={() => {
if (!deleteTarget) return;
remove.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
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;