mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 17:50:54 +00:00
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import { Navigate, useLocation, useParams } from "react-router-dom";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
|
import type { ColumnDef } from "@tanstack/react-table";
|
|
import { Loader2 } from "lucide-react";
|
|
import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core";
|
|
|
|
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
|
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
|
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
|
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
|
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 {
|
|
useApprovalChain,
|
|
useCargoTypeParentOptions,
|
|
useContainerTypeOptions,
|
|
useLiveRateOptions,
|
|
useRateWorkflow,
|
|
useRuleEngineList,
|
|
useRuleEngineMutations,
|
|
} from "@/hooks/rule-engine/useRuleEngine";
|
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
|
import {
|
|
DataTable,
|
|
DataTableFooter,
|
|
getCoreRowModel,
|
|
usePagination,
|
|
useReactTable,
|
|
} 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 { 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?.supportsSearch, search, pagination.pageIndex, pagination.pageSize],
|
|
);
|
|
|
|
const { data, isLoading, isError, error } = useRuleEngineList(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
listParams,
|
|
);
|
|
|
|
const { create, update, remove } = useRuleEngineMutations(
|
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
|
);
|
|
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 usesLiveRateField = Boolean(
|
|
config?.formFields.some((f) => f.name === "rateId"),
|
|
);
|
|
|
|
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
|
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
|
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 === "rateId") {
|
|
return {
|
|
...field,
|
|
type: "select" as const,
|
|
options: liveRateOptions ?? [],
|
|
};
|
|
}
|
|
return field;
|
|
});
|
|
}, [config, cargoParentOptions, containerTypeOptions, liveRateOptions]);
|
|
|
|
const rows = data?.data ?? [];
|
|
const meta = data?.meta;
|
|
const pageCount = meta?.totalPages ?? 1;
|
|
|
|
const filteredRows = useMemo(() => {
|
|
if (config?.supportsSearch || !search.trim()) return rows;
|
|
const q = search.trim().toLowerCase();
|
|
return rows.filter((row) =>
|
|
JSON.stringify(row).toLowerCase().includes(q),
|
|
);
|
|
}, [rows, search, config?.supportsSearch]);
|
|
|
|
const paginationState = useMemo(
|
|
() => ({
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount: meta?.total ?? filteredRows.length,
|
|
}),
|
|
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
|
|
);
|
|
|
|
|
|
const handleApproveRate = useCallback(
|
|
(record: RuleEngineRecord) => {
|
|
approve.mutate(String(record.id));
|
|
},
|
|
[approve],
|
|
);
|
|
|
|
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: 140,
|
|
minSize: 120,
|
|
meta: {
|
|
headerClassName,
|
|
cellClassName: `${cellClassName} whitespace-nowrap`,
|
|
},
|
|
cell: ({ row }) => (
|
|
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
|
<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}
|
|
/>
|
|
</div>
|
|
),
|
|
});
|
|
|
|
return base;
|
|
}, [canManage, config, submit, handleApproveRate]);
|
|
|
|
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>) => {
|
|
if (editing?.id) {
|
|
update.mutate(
|
|
{ id: editing.id, payload: values },
|
|
{
|
|
onSuccess: () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
},
|
|
},
|
|
);
|
|
} else {
|
|
create.mutate(values, {
|
|
onSuccess: () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const itemLabel = config.label.toLowerCase();
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<Card p="lg" radius="lg" withBorder style={{ background: "white", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)" }}>
|
|
<Stack gap="md">
|
|
<RuleEngineToolbar
|
|
search={search}
|
|
onSearchChange={(v) => {
|
|
setSearch(v);
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}}
|
|
searchPlaceholder={config.searchPlaceholder}
|
|
onAdd={canManage ? openCreate : undefined}
|
|
addLabel={`Add ${config.label.replace(/s$/, "")}`}
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
/>
|
|
|
|
{viewMode === "table" ? (
|
|
<DataTable
|
|
columns={columns}
|
|
data={filteredRows}
|
|
status={tableStatus}
|
|
error={
|
|
isError
|
|
? {
|
|
message: "Failed to load data",
|
|
description:
|
|
error instanceof Error ? error.message : "Unknown error",
|
|
}
|
|
: undefined
|
|
}
|
|
emptyMessage={`No ${itemLabel} found.`}
|
|
pagination={paginationState}
|
|
tableOptions={{
|
|
manualPagination: true,
|
|
pageCount,
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
}}
|
|
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
|
|
footerClassName="border-t border-border bg-card"
|
|
footer={({ table, pagination: footerPagination }) => (
|
|
<DataTableFooter
|
|
table={table}
|
|
pagination={footerPagination}
|
|
options={{
|
|
labels: {
|
|
showing: "Showing",
|
|
ofLabel: "of",
|
|
items: itemLabel,
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
) : (
|
|
<RuleEngineCardGrid
|
|
config={config}
|
|
rows={filteredRows}
|
|
status={tableStatus}
|
|
emptyMessage={`No ${itemLabel} found.`}
|
|
itemLabel={itemLabel}
|
|
pagination={paginationState}
|
|
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) ||
|
|
(usesLiveRateField && liveRateOptionsLoading)
|
|
}
|
|
onSubmit={handleFormSubmit}
|
|
/>
|
|
|
|
<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"
|
|
disabled={remove.isPending}
|
|
onClick={() => {
|
|
if (!deleteTarget) return;
|
|
remove.mutate(deleteTarget.id, {
|
|
onSuccess: () => setDeleteTarget(null),
|
|
});
|
|
}}
|
|
leftSection={remove.isPending && <Loader2 size={16} />}
|
|
>
|
|
{remove.isPending ? "Deleting..." : "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">
|
|
<Loader2 size={32} style={{ animation: "spin 1s linear infinite" }} />
|
|
</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>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default RuleEngineResourcePage;
|