import { useAuth } from "@/auth/useAuth"; import { resolveLandingPath } from "@/lib/landing"; 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 { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal"; 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, useShippingLineCompanyOptions, useWagonTypeOptions, useYardOptions, type YardOption, usePriorityRuleWorkflow, useRateChangeWorkflow, useRateWorkflow, useRuleEngineList, useRuleEngineMutations, useRuleEngineOrderList, useRuleEngineOrderMutations, } from "@/hooks/rule-engine/useRuleEngine"; import { DEFAULT_CONFIGURATION_SLUG, DEFAULT_RULES_SLUG, ROUTE_SCOPED_TRIGGERS, RULE_ENGINE_CATEGORY_BASE_PATH, RULE_ENGINE_SELECT_NONE, getRuleEngineResource, rateUnitOptions, 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, end: "origin" | "destination", ): { label: string; value: string }[] => { // A shipping-line rate names its shape in its own fields and is always // import; map it onto the appliesTo/direction pair the rest of this function // reads so the country narrowing is shared rather than duplicated. if (values.isShippingLineRate === true) { if (!values.shippingLineCompanyId) return []; const isBase = values.shippingLineRateKind === "BASE"; values = { ...values, appliesTo: isBase ? String(values.shippingLineCargoKind ?? "") : "OTHER", tradeDirection: "IMPORT", }; } 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 and fuel are sold per // direction + route, so their yard dropdowns narrow exactly like base // freight. (appliesTo === "OTHER" && ROUTE_SCOPED_TRIGGERS.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 === "DOMESTIC") { // A fuel rate's intercity lane — stays inside Ethiopia. country = "Ethiopia"; } else { 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( config?.listTabs?.[0]?.key ?? "", ); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleteTarget, setDeleteTarget] = useState( null, ); const [chainOpen, setChainOpen] = useState(false); // Yards only: which desks work at this yard (input to yard access scoping). const [desksYard, setDesksYard] = useState | null>( null, ); 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"; // No error modal here: the workflow falls back to a toast when no handler is // passed, which keeps failures visible without a dialog to dismiss. const rateChangeWorkflow = useRateChangeWorkflow( Boolean(isRates && canView), ); const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates")); /** rateId → its pending change, for the row badge. */ const pendingByRateId = useMemo(() => { const map = new Map(); 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(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); // No "None" on rates: a rate's container scope is either a real type or the // field is hidden entirely, so offering None only invites an unscoped rate. const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } = useContainerTypeOptions(false, usesContainerTypeField); const { data: liveRateOptions, isLoading: liveRateOptionsLoading } = useLiveRateOptions(usesLiveRateField); const usesShippingLineField = Boolean( config?.formFields.some((f) => f.name === "shippingLineCompanyId"), ); const { data: shippingLineOptions, isLoading: shippingLineOptionsLoading, } = useShippingLineCompanyOptions(usesShippingLineField); 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 []; // Last-mile bands (both modes): creating uses the multi-row tier list (one // rate per tier, each tier carrying its own rate value); editing an // existing band row keeps the single From/To/value fields (a rate row IS // one band). const bandFields = config.formFields.filter((field) => { if (config.slug !== "rates") return true; if (field.type === "tierList") return !editing; if (editing) return true; // On create the tier rows carry From/To/value — drop the single fields, // including the last-mile "Rate value" (the non-last-mile one keeps its // own showIf). if ( field.name === "rateValue" && field.showWhen?.field === "appliesTo" && field.showWhen.equals.includes("LAST_MILE") ) { return false; } return field.name !== "minKm" && field.name !== "maxKm"; }); return bandFields.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) => 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 ?? [], }; } // Rate units follow the picked commodity: a per-item (break-bulk) cargo // is priced per item where a weighed one is priced per ton. if (field.name === "rateUnit" && field.optionsFromValues) { return { ...field, optionsFromValues: (values: Record) => rateUnitOptions( values, (cargoLeafOptions ?? []).find( (o) => o.value === String(values.cargoTypeId ?? ""), )?.unitOfMeasure ?? "", ), }; } if (field.name === "shippingLineCompanyId") { return { ...field, type: "select" as const, options: shippingLineOptions ?? [], }; } 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) => (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) => 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[] => { 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 }) => { const cell = formatCell(row.original[col.accessorKey], col.format, row.original); // 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 ( {cell} {Number(change.payload.rateValue).toLocaleString()} pending ); }, })); 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.slug === "yards" ? ( ) : null} {config.orderConfig && canUpdateControls ? ( ) : null} { 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} />
), }); return base; }, [ canUpdateControls, config, isRates, pendingByRateId, 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" && values.isShippingLineRate === true) { // A shipping-line rate asks its shape as "base freight vs surcharge" + // "container vs bulk"; the API takes the same appliesTo/trigger pair as a // customer rate, so translate here and drop the form-only fields. Always // import (the only direction a line ships) and always USD. const { isShippingLineRate: _toggle, shippingLineRateKind, shippingLineCargoKind, ...rest } = values; void _toggle; const isBase = shippingLineRateKind === "BASE"; payload = { ...rest, appliesTo: isBase ? String(shippingLineCargoKind ?? "CONTAINER") : "OTHER", trigger: isBase ? "ALWAYS" : values.trigger, tradeDirection: "IMPORT", currency: "USD", }; if (editing?.id && editing.status === "LIVE") { rateChangeWorkflow.submit.mutate( { rateId: String(editing.id), update: payload }, { onSuccess: () => { setFormOpen(false); setEditing(null); }, }, ); return; } } else 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"; // Last mile: the form's calculation mode picks the unit (bulk = per // ton·km, container = per km + distance band) and the currency stays as // chosen (birr or dollar). Everything else remains USD-only. const isLastMile = values.appliesTo === "LAST_MILE"; // The shipping-line toggle is form-only — the API's whitelist rejects the // whole payload if it leaks through ("property isShippingLineRate should // not exist"). const { lastMileMode, isShippingLineRate: _toggle, ...rest } = values; void _toggle; payload = { ...rest, currency: isLastMile ? (values.currency ?? "ETB") : "USD", trigger: isSurcharge ? values.trigger : "ALWAYS", ...(isLastMile ? { // Empty "To km" means an open-ended band — send null so an // edit can clear a previously-set ceiling. On create the tier // spread below overrides the band fields per tier. maxKm: values.maxKm ?? null, ...(lastMileMode === "BULK" ? { rateUnit: "PER_TON_KM", containerTypeId: undefined } : { rateUnit: "PER_KM" }), } : {}), }; // 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; } // Container-mode create: the tier list becomes one rate row per tier, // created sequentially so an overlap/duplicate rejection stops the batch // with its own toast instead of half-failing in parallel. const tiers = ( payload as { tiers?: Array<{ minKm: number; maxKm: number | null; rateValue: number }>; } ).tiers; if (!editing?.id && Array.isArray(tiers)) { const { tiers: _omitted, ...base } = payload as Record; void _omitted; void (async () => { try { for (const tier of tiers) { await create.mutateAsync({ ...base, ...tier }); } setFormOpen(false); setEditing(null); } catch { // The create mutation already toasted the failure; keep the dialog // open so the admin can fix the tier set and retry. } })(); 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 ( } onClick={openCreate}> {addLabel} ) : undefined } /> {isPriorityRules ? ( ) : null} {isRates ? ( ) : null} setPriorityError(null)} title="Cannot save priority rule" centered size="md" > {priorityError} {config.listTabs && ( { setActiveTab(v ?? config.listTabs![0].key); setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); }} px="md" pt="sm" > {config.listTabs.map((tab) => ( {tab.label} ))} )} { 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} /> {viewMode === "table" ? ( ( )} /> ) : ( setChainOpen(true) : undefined } onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined} onApproveRate={canUpdateControls ? handleApproveRate : undefined} /> )} setDesksYard(null)} readOnly={!canUpdateControls} yard={ desksYard ? { id: String(desksYard.id), code: String(desksYard.code ?? ""), label: String(desksYard.label ?? ""), } : null } /> {config.orderConfig ? ( { reorder.mutate(payload, { onSuccess: () => setOrderDialogOpen(false), }); }} /> ) : null} setDeleteTarget(null)} title="Delete record?" centered 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.`} 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;