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, 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(null); const [deleteTarget, setDeleteTarget] = useState( 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 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 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[] => { if (!config) return []; const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; const base: ColumnDef[] = 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 }) => (
e.stopPropagation()} data-stop-row-click> {config.orderConfig && canManage ? ( ) : null} { 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} />
), }); return base; }, [ canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount, ]); const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; if (!resourceSlug || !category) { return ; } if (!config || config.category !== category) { return ; } if (!canView) { return ; } const openCreate = () => { setEditing(null); setFormOpen(true); }; const openEdit = (record: RuleEngineRecord) => { setEditing(record); setFormOpen(true); }; const handleFormSubmit = (values: Record) => { let payload = values; if (config.slug === "rates") { payload = { ...values, currency: "USD" }; } 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()) }; } 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 ( } onClick={openCreate}> {addLabel} ) : undefined } /> { 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} /> {viewMode === "table" ? ( ( )} /> ) : ( setChainOpen(true) : undefined } onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined} onApproveRate={canManage ? handleApproveRate : undefined} /> )} {config.orderConfig ? ( { reorder.mutate(payload, { onSuccess: () => setOrderDialogOpen(false), }); }} /> ) : null} setDeleteTarget(null)} title="Delete record?" centered size="sm" > This will soft-delete the selected {config.label.toLowerCase()}{" "} record. setChainOpen(false)} title="Approval chain" centered size="md" > {chainLoading ? ( ) : ( <> {(chainData ?? []).length === 0 ? ( No approval rules configured. ) : ( {(chainData ?? []).map((step, index) => ( Step {String(step.stepOrder ?? index + 1)}:{" "} {String(step.actionLabel ?? "")} Role: {String(step.requiredRole ?? "—")} ))} )} )} ); }; export default RuleEngineResourcePage;