mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
604 lines
19 KiB
TypeScript
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;
|