import type { SidebarItem } from "@/components/layout/types"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; export type RuleEngineNavCategory = "configuration" | "rules"; export type ColumnFormat = | "text" | "code" | "boolean" | "activeBadge" | "rateStatus" | "date" | "number" | "currency" | "entityLabel" | "rateLabel"; export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio"; export interface ResourceColumn { id: string; header: string; accessorKey: string; format?: ColumnFormat; } /** Radix Select cannot use empty string as an item value; use this for optional "none" choices. */ export const RULE_ENGINE_SELECT_NONE = "__none__"; export interface FormFieldDef { name: string; label: string; type: FormFieldType; required?: boolean; optional?: boolean; options?: { label: string; value: string }[]; placeholder?: string; description?: string; disabled?: boolean; /** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */ suffix?: string; /** Hide this field when another field currently equals one of these values. */ hideWhen?: { field: string; equals: string[] }; /** * Show this field ONLY when another field currently equals one of these * values (inverse of hideWhen). When both are set, the field must satisfy * showWhen and not match hideWhen. */ showWhen?: { field: string; equals: string[] }; /** * Select options computed from other fields' current values. When set, the * form resolves the option list at render time from the live form state * instead of the static `options` list. Used for the rate unit selector, * whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly * from the fleet config's string-based `dynamicOptions` to avoid a clash.) */ optionsFromValues?: (values: Record) => { label: string; value: string }[]; /** * Derive the field's initial form value from the record being edited when it * doesn't live under `record[name]` — e.g. a multiselect of ids backed by a * relation list (`wagonTypeIds` read from `record.wagonTypes`). */ getInitialValue?: (record: Record) => unknown; /** * Fully derived field: its value is computed from the live form values on * every render and the input is locked. Used for the priority-rule min * wagon count, which always continues the previous range for the selected * type. Return null/undefined to leave the field empty (e.g. chain full). */ computeValue?: (values: Record) => number | string | null; } export interface RuleEngineOrderConfig { field: "displayOrder" | "stepOrder"; scopeField?: "requiresDirectorApproval"; label: string; } export interface RuleEngineResourceConfig { slug: RuleEngineResourceSlug; label: string; subtitle: string; category: RuleEngineNavCategory; searchPlaceholder: string; columns: ResourceColumn[]; formFields: FormFieldDef[]; supportsSearch?: boolean; orderConfig?: RuleEngineOrderConfig; /** Primary line on card view (inferred from columns when omitted). */ cardTitleKey?: string; /** Secondary line under title on card view (inferred when omitted). */ cardSubtitleKey?: string; /** Code badge on card header (inferred from code column when omitted). */ cardCodeKey?: string; } export const RULE_ENGINE_CATEGORY_BASE_PATH: Record = { configuration: "/dashboard/configuration", rules: "/dashboard/rules", }; const TRADE_DIRECTIONS = [ { label: "Import", value: "IMPORT" }, { label: "Export", value: "EXPORT" }, { label: "Both", value: "BOTH" }, ]; // Mirrors the YardCountry enum in @edr/types — the only two countries on the line. const YARD_COUNTRIES = [ { label: "Ethiopia", value: "Ethiopia" }, { label: "Djibouti", value: "Djibouti" }, ]; const APPROVAL_ROLES = [ { label: "Line staff", value: "LINE_STAFF" }, { label: "Director", value: "DIRECTOR" }, { label: "CEO", value: "CEO" }, ]; /** * Friendly, admin-facing rate categories. Choosing one drives which fields the * Rate form shows (see the `rates` resource below). Base-freight categories * carry a trade direction + container/bulk scope; OTHER is for surcharges. */ const RATE_APPLIES_TO = [ { label: "Bulk (base freight)", value: "BULK" }, { label: "Container (base freight)", value: "CONTAINER" }, { label: "Intercity (base freight)", value: "INTERCITY" }, { label: "First mile", value: "FIRST_MILE" }, { label: "Last mile", value: "LAST_MILE" }, { label: "Other (surcharge)", value: "OTHER" }, ]; /** Surcharge triggers — only relevant when Applies to = Other. */ const RATE_TRIGGERS = [ { label: "Hazardous cargo", value: "HAZARDOUS" }, { label: "Overweight (per excess ton)", value: "OVERWEIGHT" }, { label: "Reefer cargo", value: "REEFER" }, { label: "Shipping line mapped", value: "SHIPPING_LINE" }, { label: "Consolidation", value: "CONSOLIDATION" }, { label: "Cancellation", value: "CANCELLATION" }, { label: "Demurrage", value: "DEMURRAGE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" }, ]; const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value }); /** * Valid weighting units for a rate shape — mirrors the API's * `allowedRateUnits`. The unit is driven by the *type* being billed: containers * bill per container, bulk per ton, overweight always per excess ton, etc. Kept * in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts. */ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => { if (appliesTo === "OTHER") { switch (trigger) { case "OVERWEIGHT": return ["PER_TON"]; case "REEFER": case "HAZARDOUS": case "DEMURRAGE": return ["PER_CONTAINER", "PER_TON"]; case "CANCELLATION": return ["FLAT", "PER_INVOICE"]; case "CUSTOMS_CLEARANCE": // Flat per clearance (ONE_TIME) / per shipment request (GENERAL). return ["FLAT"]; case "CONSOLIDATION": case "SHIPPING_LINE": case "PIL_EXTRA_FEE": return ["PER_CONTAINER", "FLAT"]; default: return ["FLAT", "PER_TON", "PER_CONTAINER"]; } } switch (appliesTo) { case "CONTAINER": return ["PER_CONTAINER", "PER_WAGON"]; case "BULK": return ["PER_TON", "PER_WAGON"]; case "INTERCITY": return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"]; case "FIRST_MILE": case "LAST_MILE": return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"]; default: return ["FLAT"]; } }; const rateUnitOptions = (values: Record) => { const appliesTo = String(values.appliesTo ?? ""); const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS"; if (!appliesTo) return []; return allowedRateUnits(appliesTo, trigger).map(unitOption); }; const CURRENCIES = [ { label: "USD", value: "USD" }, { label: "ETB", value: "ETB" }, ]; const PRIORITY_CONFIG_TYPES = [ { label: "Wagon count", value: "WAGON" }, { label: "Payment currency", value: "CURRENCY" }, { label: "Customs clearance", value: "CUSTOMS" }, ]; const codeColumn = (key: string, header = "Code"): ResourceColumn => ({ id: key, header, accessorKey: key, format: "code", }); const activeColumn: ResourceColumn = { id: "isActive", header: "Status", accessorKey: "isActive", format: "activeBadge", }; export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { slug: "cargo-types", label: "Cargo Types", category: "configuration", subtitle: "Manage freight cargo classification and approval rules", searchPlaceholder: "Search cargo types by name or code...", supportsSearch: true, orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ codeColumn("code"), { id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" }, { id: "requiresDirectorApproval", header: "Director approval", accessorKey: "requiresDirectorApproval", format: "boolean", }, { id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" }, activeColumn, ], formFields: [ { name: "cargoTypeName", label: "Cargo type name", type: "text", required: true }, { name: "parentGroupId", label: "Parent group", type: "select", optional: true, placeholder: "Select parent cargo type (optional)", }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ], }, { slug: "container-types", label: "Container Types", category: "configuration", subtitle: "Configure container sizes", searchPlaceholder: "Search container types...", supportsSearch: true, orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ codeColumn("code"), { id: "label", header: "Label", accessorKey: "label" }, { id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" }, { id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" }, activeColumn, ], formFields: [ { name: "label", label: "Label", type: "text", required: true }, { name: "sizeFt", label: "Size (ft)", type: "number", required: true }, // Options injected at render from useWagonTypeOptions (RuleEngineResourcePage). { name: "wagonTypeIds", label: "Wagon types", type: "multiselect", required: true, description: "Wagon types that can carry this container during train scheduling (one container size per wagon at a time).", getInitialValue: (record) => ((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id), }, { name: "isOpenTop", label: "Open top", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ], }, { slug: "wagon-types", label: "Wagon Types", category: "configuration", subtitle: "Configure wagon classes used for capacity and train planning", searchPlaceholder: "Search wagon types by name or code...", // No supportsSearch: wagon-types is served by its own module, which does // not implement server-side search (unlike the 9 rule-engine resources). cardTitleKey: "name", columns: [ codeColumn("code"), { id: "name", header: "Name", accessorKey: "name" }, { id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" }, { id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" }, { id: "tareWeightTons", header: "Tare (t)", accessorKey: "tareWeightTons", format: "number" }, { id: "supportedLoadTypes", header: "Load types", accessorKey: "supportedLoadTypes", }, activeColumn, ], formFields: [ { name: "name", label: "Name", type: "text", required: true }, { name: "capacityTons", label: "Capacity (tons)", type: "number", required: true }, { name: "lengthMeters", label: "Length (meters)", type: "number", required: true }, // The locomotive's pull limit is a GROSS limit, so capacity planning charges // `cargo + wagons × tare` against it. The API rejects a create without this. { name: "tareWeightTons", label: "Tare weight (tons)", type: "number", required: true, description: "Empty wagon weight — counts against the locomotive's pull limit alongside the cargo", }, { name: "supportedLoadTypes", label: "Supported load types", type: "textarea", optional: true, placeholder: "CONTAINER, BULK", }, { name: "isActive", label: "Active", type: "boolean" }, ], }, { slug: "priority-configs", label: "Priority Rules", category: "rules", subtitle: "Wagon-count, payment-currency, and customs scoring rules", searchPlaceholder: "Search priority rules...", supportsSearch: true, orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ { id: "type", header: "Type", accessorKey: "type" }, { id: "currency", header: "Currency", accessorKey: "currency" }, { id: "minWagonCount", header: "Min wagons", accessorKey: "minWagonCount", format: "number" }, { id: "maxWagonCount", header: "Max wagons", accessorKey: "maxWagonCount", format: "number" }, { id: "scorePoints", header: "Points", accessorKey: "scorePoints", format: "number" }, activeColumn, ], formFields: [ { name: "type", label: "Type", type: "select", required: true, options: PRIORITY_CONFIG_TYPES, placeholder: "Wagon count or payment currency", }, { name: "currency", label: "Currency", type: "select", optional: true, options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES], placeholder: "Select a currency", hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] }, }, { name: "minWagonCount", label: "Min wagon count", type: "number", required: true, disabled: true, description: "Auto-filled — continues the previous range for the selected type", }, { name: "maxWagonCount", label: "Max wagon count", type: "number", required: true, description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15", }, { name: "scorePoints", label: "Score points", type: "number", required: true }, { name: "isActive", label: "Active", type: "boolean" }, ], }, { slug: "service-types", label: "Service Types", category: "configuration", subtitle: "Freight service offerings and booking options", searchPlaceholder: "Search service types...", supportsSearch: true, orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ codeColumn("code"), { id: "serviceName", header: "Service name", accessorKey: "serviceName" }, { id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" }, activeColumn, ], formFields: [ { name: "serviceName", label: "Service name", type: "text", required: true }, { name: "description", label: "Description", type: "textarea" }, { name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" }, { name: "includesFirstMile", label: "Includes first mile", type: "boolean" }, { name: "includesLastMile", label: "Includes last mile", type: "boolean" }, { name: "includesCustoms", label: "Includes customs", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ], }, { slug: "weight-limit-rules", label: "Weight Limit Rules", category: "rules", subtitle: "VGM limits by container and trade direction", searchPlaceholder: "Search weight limit rules...", supportsSearch: true, cardTitleKey: "containerType", cardSubtitleKey: "tradeDirection", columns: [ { id: "containerType", header: "Container", accessorKey: "containerType", format: "entityLabel", }, { id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" }, { id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" }, { id: "maxCapacityTons", header: "Max capacity (t)", accessorKey: "maxCapacityTons", format: "number", }, ], formFields: [ { name: "containerTypeId", label: "Container type", type: "select", required: true, placeholder: "Select container type", }, { name: "tradeDirection", label: "Trade direction", type: "select", required: true, options: TRADE_DIRECTIONS, }, { name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true }, { name: "maxCapacityTons", label: "Max capacity (tons)", type: "number", optional: true, description: "Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).", }, ], }, { slug: "yards", label: "Yards", category: "configuration", subtitle: "Terminal and yard locations", searchPlaceholder: "Search yards...", supportsSearch: true, orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ codeColumn("code"), { id: "label", header: "Label", accessorKey: "label" }, { id: "country", header: "Country", accessorKey: "country" }, { id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" }, activeColumn, ], formFields: [ { name: "label", label: "Label", type: "text", required: true }, { name: "country", label: "Country", type: "select", required: true, options: YARD_COUNTRIES, }, { name: "isActive", label: "Active", type: "boolean" }, ], }, { slug: "shipping-lines", label: "Shipping Lines", category: "configuration", subtitle: "Shipping line codes and pricing mappings", searchPlaceholder: "Search shipping lines...", supportsSearch: true, columns: [ codeColumn("code"), { id: "label", header: "Label", accessorKey: "label" }, { id: "mappedToCode", header: "Mapped to", accessorKey: "mappedToCode" }, { id: "showExtraFeeNotice", header: "Extra fee notice", accessorKey: "showExtraFeeNotice", format: "boolean", }, activeColumn, ], formFields: [ { name: "code", label: "Code", type: "text", required: true }, { name: "label", label: "Label", type: "text", required: true }, { name: "mappedToCode", label: "Mapped to code", type: "text" }, { name: "showExtraFeeNotice", label: "Show extra fee notice", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ], }, { slug: "rates", label: "Rates", category: "rules", cardTitleKey: "appliesTo", cardSubtitleKey: "currency", subtitle: "Freight rates and approval workflow", searchPlaceholder: "Search rates by type or status...", supportsSearch: true, columns: [ { id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" }, { id: "trigger", header: "Trigger", accessorKey: "trigger" }, { id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" }, { id: "rateUnit", header: "Unit", accessorKey: "rateUnit" }, { id: "status", header: "Status", accessorKey: "status", format: "rateStatus" }, ], formFields: [ { name: "appliesTo", label: "Applies to", type: "select", required: true, options: RATE_APPLIES_TO, description: "Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.", }, // ── Surcharge trigger — only when Applies to = Other ────────────────── { name: "trigger", label: "Surcharge trigger", type: "select", required: true, options: RATE_TRIGGERS, placeholder: "What makes this surcharge apply?", showWhen: { field: "appliesTo", equals: ["OTHER"] }, }, // ── Trade direction — Bulk & Container only (intercity is domestic) ─── { name: "tradeDirection", label: "Trade direction", type: "select", required: true, options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"), showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] }, }, // ── Container type — Container & Intercity ──────────────────────────── { name: "containerTypeId", label: "Container type", type: "select", optional: true, placeholder: "Select container type (optional)", showWhen: { field: "appliesTo", equals: ["CONTAINER", "INTERCITY"] }, }, // ── Bulk cargo (leaf commodity) — Bulk & Intercity ─────────────────── { name: "cargoTypeId", label: "Bulk cargo type", type: "select", optional: true, placeholder: "Select bulk commodity (optional)", showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] }, }, { name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" }, // Unit choices are driven by the rate shape (appliesTo + trigger). Overweight // is always per excess ton, so the unit field is hidden for it — the API // forces PER_TON regardless. { name: "rateUnit", label: "Rate unit", type: "select", required: true, optionsFromValues: rateUnitOptions, description: "Weighting basis — options depend on what the rate applies to.", hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] }, }, ], }, { slug: "approval-rules", label: "Approval Rules", category: "rules", cardTitleKey: "actionLabel", cardSubtitleKey: "requiredRole", subtitle: "Multi-step booking approval chain", searchPlaceholder: "Search approval rules...", supportsSearch: true, orderConfig: { field: "stepOrder", scopeField: "requiresDirectorApproval", label: "Step order", }, columns: [ { id: "requiresDirectorApproval", header: "Director chain", accessorKey: "requiresDirectorApproval", format: "boolean", }, { id: "stepOrder", header: "Step", accessorKey: "stepOrder", format: "number" }, { id: "requiredRole", header: "Role", accessorKey: "requiredRole" }, { id: "actionLabel", header: "Action", accessorKey: "actionLabel" }, { id: "blocksRole", header: "Blocks", accessorKey: "blocksRole" }, ], formFields: [ { name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" }, { name: "requiredRole", label: "Required role", type: "select", required: true, options: APPROVAL_ROLES, }, { name: "actionLabel", label: "Action label", type: "text", required: true }, { name: "blocksRole", label: "Blocks role", type: "select", optional: true, options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...APPROVAL_ROLES], }, ], }, ]; export const RULE_ENGINE_RESOURCE_MAP = Object.fromEntries( RULE_ENGINE_RESOURCES.map((r) => [r.slug, r]), ) as Record; export const getRuleEngineResource = (slug: string): RuleEngineResourceConfig | undefined => RULE_ENGINE_RESOURCE_MAP[slug as RuleEngineResourceSlug]; export const ruleEngineResourcePath = (slug: RuleEngineResourceSlug): string => { const resource = RULE_ENGINE_RESOURCE_MAP[slug]; return `${RULE_ENGINE_CATEGORY_BASE_PATH[resource.category]}/${slug}`; }; export const getCategorySidebarChildren = ( category: RuleEngineNavCategory, ): SidebarItem[] => RULE_ENGINE_RESOURCES.filter((r) => r.category === category).map((r) => ({ label: r.label, href: ruleEngineResourcePath(r.slug), })); export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types"; export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-configs"; /** @deprecated Use DEFAULT_CONFIGURATION_SLUG */ export const DEFAULT_RULE_ENGINE_SLUG = DEFAULT_CONFIGURATION_SLUG;