import type { SidebarItem } from "@/components/layout/types"; import { ruleEngineViewKey } from "@/lib/permissions"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; export type RuleEngineNavCategory = "configuration" | "rules"; export type ColumnFormat = | "text" | "code" | "boolean" | "activeBadge" | "rateStatus" | "validityBadge" | "date" | "number" | "currency" | "entityLabel" | "rateLabel"; export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList"; 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; /** Editable on create, locked when editing an existing record. */ disabledOnEdit?: 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[] }; /** * Show this field only when the predicate accepts the live form values — for * visibility that depends on more than one field, which `showWhen` cannot * express (the intercity container/bulk pickers hang off both `appliesTo` * and `intercityKind`). Combines with showWhen/hideWhen: all must pass. */ showIf?: (values: Record) => boolean; /** * 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; /** Pre-selected value on create (no record yet) — e.g. last-mile currency = ETB. */ defaultValue?: string; /** * 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; } /** * A category tab above a resource list. The active tab's `filters` are sent to * the list endpoint verbatim, so filtering happens server-side (values may be * comma-separated lists, e.g. appliesTo: "FIRST_MILE,LAST_MILE"). */ export interface RuleEngineListTab { key: string; label: string; filters: { appliesTo?: string; trigger?: string; /** * "true" = only shipping-line rates, "false" = only standard customer * rates. Sent as a string because tab filters go on the query string * verbatim. */ isShippingLineRate?: string; }; } export interface RuleEngineResourceConfig { slug: RuleEngineResourceSlug; label: string; subtitle: string; category: RuleEngineNavCategory; searchPlaceholder: string; columns: ResourceColumn[]; formFields: FormFieldDef[]; supportsSearch?: boolean; orderConfig?: RuleEngineOrderConfig; /** Server-filtered category tabs rendered above the list (rates page). */ listTabs?: RuleEngineListTab[]; /** 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" }, ]; /** * The three role strings the approval chain was hardcoded to before it was * driven by IAM position types. Kept only so rows still stored against them * render a readable label instead of a blank select — the live options come * from GET /approval-rules/position-types (see `useApprovalRoleOptions`). */ export const LEGACY_APPROVAL_ROLES = [ { label: "Line staff (legacy)", value: "LINE_STAFF" }, { label: "Director (legacy)", value: "DIRECTOR" }, { label: "CEO (legacy)", 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 (export only — import derives from container price)", value: "OVERWEIGHT", }, { label: "Reefer cargo", value: "REEFER" }, { label: "Empty container return (import, per route + container type)", value: "WITH_RETURN", }, { label: "Shipping line mapped", value: "SHIPPING_LINE" }, { label: "Penalty", value: "CONSOLIDATION" }, { label: "Lashing (bulk, per cargo type)", value: "LASHING" }, { label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, { label: "Ethiopian customs clearance service fee (Ethiopian-side-only services)", value: "ETHIOPIAN_CUSTOMS_CLEARANCE", }, { label: "Fuel (per lane + cargo type)", value: "FUEL" }, ]; /** * Fuel lanes: import/export like base freight, plus a domestic intercity lane * (stored as DOMESTIC — matches the booking's own trade direction). */ const FUEL_TRADE_DIRECTIONS = [ { label: "Import", value: "IMPORT" }, { label: "Export", value: "EXPORT" }, { label: "Intercity", value: "DOMESTIC" }, ]; /** * Intercity runs inside Ethiopia and can carry either boxes or bulk, but the * two price differently. The admin says which up front and the form then asks * for the matching scope field — this choice is not stored on the rate itself; * the API reads container-vs-bulk back off whichever scope field was filled. */ const INTERCITY_KINDS = [ { label: "Container", value: "CONTAINER" }, { label: "Bulk", value: "BULK" }, ]; /** * A shipping-line rate: priced for one carrier's own bookings instead of for * every customer. The toggle drives the whole form — until a line is picked * there is nothing to configure, and the shape questions (base freight vs * surcharge, container vs bulk) are asked only after it is. */ const isShippingLineRate = (values: Record) => values.isShippingLineRate === true; /** A shipping-line rate whose owning line has been chosen — the rest unlocks. */ const hasShippingLine = (values: Record) => isShippingLineRate(values) && Boolean(values.shippingLineCompanyId); /** * What a shipping-line rate prices. Deliberately narrower than the customer * form's `appliesTo`: a line buys base rail freight (its own containers or * bulk) or a surcharge, and nothing else — intercity and first/last mile are * customer products. */ const SHIPPING_LINE_RATE_KINDS = [ { label: "Base freight", value: "BASE" }, { label: "Surcharge", value: "SURCHARGE" }, ]; /** Container vs bulk, asked once a shipping-line base-freight rate is chosen. */ const SHIPPING_LINE_CARGO_KINDS = [ { label: "Container", value: "CONTAINER" }, { label: "Bulk", value: "BULK" }, ]; /** True when the rate being edited is base rail freight, which is priced per leg. */ const isBaseFreightRate = (values: Record) => ["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? "")); /** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */ export const ROUTE_SCOPED_TRIGGERS = [ "CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL", ]; /** * Rates priced per leg: base rail freight, plus the customs clearance fees and * the empty-container return surcharge (sold per route + container type). */ const isRouteScopedRate = (values: Record) => // A shipping line's base freight is priced per leg exactly like a customer's; // its surcharges are route-scoped on the same triggers. (isShippingLineRate(values) ? hasShippingLine(values) && (values.shippingLineRateKind === "BASE" || ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? ""))) : isBaseFreightRate(values)) || (String(values.appliesTo ?? "") === "OTHER" && ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? ""))); /** * Surcharges sold per cargo kind: the admin says container or bulk, then names * the container type or bulk commodity the fee covers. */ const isCargoKindTrigger = (values: Record) => ["CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION"].includes( String(values.trigger ?? ""), ); 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. A * rate scoped to a break-bulk commodity (unit of measure = PER_ITEM) offers * PER_ITEM wherever a weighed one offers PER_TON. Kept in sync with * apps/edr-freight-api/.../entities/rate-unit.util.ts. */ const allowedRateUnits = ( appliesTo: string, trigger: string, cargoKind = "", cargoUnitOfMeasure = "", ): string[] => { const units = unitsForShape(appliesTo, trigger, cargoKind); return cargoUnitOfMeasure === "PER_ITEM" ? units.map((u) => (u === "PER_TON" ? "PER_ITEM" : u)) : units; }; const unitsForShape = ( appliesTo: string, trigger: string, cargoKind = "", ): string[] => { if (appliesTo === "OTHER") { switch (trigger) { case "OVERWEIGHT": return ["PER_TON"]; case "REEFER": case "HAZARDOUS": case "DEMURRAGE": return ["PER_CONTAINER", "PER_TON"]; case "WITH_RETURN": // Container-only service — per returned container, per wagon, or flat. return ["PER_CONTAINER", "PER_WAGON", "FLAT"]; case "CANCELLATION": // Wagon cancellation fee — scales with the cancelled wagons only. return ["PER_WAGON"]; case "CUSTOMS_CLEARANCE": case "ETHIOPIAN_CUSTOMS_CLEARANCE": // Per cargo kind: container fees per box/wagon, bulk per ton/wagon. return cargoKind === "BULK" ? ["PER_TON", "PER_WAGON"] : ["PER_CONTAINER", "PER_WAGON"]; case "LASHING": // Bulk-only cargo securing — per ton or per wagon. return ["PER_TON", "PER_WAGON"]; case "FUEL": // Per wagon (wagons × rate) or per liter (base liters × rate, once). return ["PER_WAGON", "PER_LITER"]; 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": return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"]; case "LAST_MILE": // PER_KM = container mode (distance-banded), PER_TON_KM = bulk mode. return ["PER_KM", "PER_TON_KM", "PER_CONTAINER", "PER_TON", "FLAT"]; default: return ["FLAT"]; } }; /** * Unit choices for the rate form. `cargoUnitOfMeasure` is how the bulk * commodity picked in the form is counted (PER_TON / PER_ITEM) — injected by * RuleEngineResourcePage, which is the layer that has the cargo type list. */ export const rateUnitOptions = ( values: Record, cargoUnitOfMeasure = "", ) => { // A shipping-line rate answers the same two questions under different names — // map them onto the shape the unit table is keyed by. Base freight for a line // is CONTAINER/BULK freight; a line surcharge is OTHER + its trigger. if (isShippingLineRate(values)) { const { shippingLineRateKind: kind, shippingLineCargoKind: cargoKind } = values; if (kind === "BASE") { if (cargoKind !== "CONTAINER" && cargoKind !== "BULK") return []; return allowedRateUnits( String(cargoKind), "ALWAYS", "", cargoUnitOfMeasure, ).map(unitOption); } if (kind === "SURCHARGE" && values.trigger) { return allowedRateUnits( "OTHER", String(values.trigger), String(values.cargoKind ?? ""), cargoUnitOfMeasure, ).map(unitOption); } return []; } const appliesTo = String(values.appliesTo ?? ""); const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS"; if (!appliesTo) return []; return allowedRateUnits( appliesTo, trigger, String(values.cargoKind ?? ""), cargoUnitOfMeasure, ).map(unitOption); }; const CURRENCIES = [ { label: "ETB (Birr)", value: "ETB" }, { label: "USD", value: "USD" }, ]; 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: "unitOfMeasure", header: "Billed by", accessorKey: "unitOfMeasure" }, { 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: "unitOfMeasure", label: "Billed by", type: "select", optional: true, placeholder: "Not set (container/legacy cargo)", description: "Bulk storage/demurrage bills per ton for Tonnage cargo, per unit for Countable cargo (e.g. Machinery, Truck, Automobile, Livestock).", options: [ { label: "Tonnage (per ton)", value: "PER_TON" }, { label: "Countable (per item)", value: "PER_ITEM" }, ], }, { name: "parentGroupId", label: "Parent group", type: "select", optional: true, placeholder: "Select parent cargo type (optional)", }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "hasLashing", label: "Charge lashing fee", type: "boolean" }, { name: "hasFuel", label: "Charge fuel fee", type: "boolean", description: "Bookings of this cargo incur the fuel surcharge (configure the FUEL rate per lane under Rates).", }, { 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, disabledOnEdit: 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: "code", label: "Code", type: "text", required: true }, { 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: "truck-types", label: "Truck Types", category: "configuration", subtitle: "Configure the truck configurations vehicles are registered against — capacity and whether a trailer applies", searchPlaceholder: "Search truck types by name or code...", cardTitleKey: "name", columns: [ codeColumn("code"), { id: "name", header: "Name", accessorKey: "name" }, { id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" }, { id: "hasTrailer", header: "Has trailer", accessorKey: "hasTrailer" }, activeColumn, ], formFields: [ { name: "code", label: "Code", type: "text", required: true }, { name: "name", label: "Name", type: "text", required: true }, { name: "capacityTons", label: "Capacity (tons)", type: "number", optional: true, description: "Pre-fills the capacity of every vehicle registered on this type", }, // Drives the trailer plate on vehicle registration: a rigid truck (Casoni) // has none, so registering one with a trailer plate is rejected. { name: "hasTrailer", label: "Pulls a trailer", type: "boolean", description: "Off for a rigid truck (e.g. Casoni) — its registration has no trailer plate", }, { name: "description", label: "Description", type: "textarea", optional: true }, { name: "isActive", label: "Active", type: "boolean" }, ], }, { slug: "transit-agents", label: "Transit Agents", category: "configuration", subtitle: "Djibouti transit officers GL Djibouti may assign to a shipment — each carries a validity window", searchPlaceholder: "Search transit agents by name...", cardTitleKey: "name", columns: [ { id: "name", header: "Name", accessorKey: "name" }, { id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" }, { id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" }, { id: "validityStatus", header: "Validity", accessorKey: "validityStatus", format: "validityBadge", }, activeColumn, ], formFields: [ { name: "name", label: "Name", type: "text", required: true }, { name: "validFrom", label: "Valid from", type: "date", required: true }, { name: "validTo", label: "Valid to", type: "date", required: true, description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one", }, { name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" }, ], }, { slug: "yard-distances", label: "Yard Distances", category: "configuration", subtitle: "Rail distance between yard pairs — routes read their segment km from here", searchPlaceholder: "Search by yard name or code...", supportsSearch: true, cardTitleKey: "fromYardLabel", cardSubtitleKey: "toYardLabel", columns: [ { id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" }, { id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" }, { id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" }, ], formFields: [ // Options injected at render from useYardOptions (RuleEngineResourcePage). { name: "fromYardId", label: "From yard", type: "select", required: true, placeholder: "Select yard" }, { name: "toYardId", label: "To yard", type: "select", required: true, placeholder: "Select yard" }, { name: "distanceKm", label: "Distance (km)", type: "number", required: true, description: "Symmetric — one entry covers both directions. Route segments between these yards use this value.", }, ], }, { 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: "No upper limit — must be at least the min wagon count", }, { 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: "includesEthiopianCustomsOnly", label: "Ethiopian customs only", type: "boolean", description: "EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate instead of the standard one.", showIf: (v) => v.includesCustoms === true, }, { 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: "hasFacility", header: "Facility", accessorKey: "hasFacility", format: "boolean", }, { id: "hasContainerFacilityOrigin", header: "Container origin", accessorKey: "hasContainerFacilityOrigin", format: "boolean", }, { id: "hasContainerFacilityDestination", header: "Container dest.", accessorKey: "hasContainerFacilityDestination", format: "boolean", }, { id: "hasBulkFacilityOrigin", header: "Bulk origin", accessorKey: "hasBulkFacilityOrigin", format: "boolean", }, { id: "hasBulkFacilityDestination", header: "Bulk dest.", accessorKey: "hasBulkFacilityDestination", format: "boolean", }, { 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: "hasFacility", label: "Has load/unload facility", type: "boolean", description: "This yard can load and unload cargo. Intercity bookings can only be loaded at their origin and unloaded at their destination when it is a facility.", }, { name: "hasContainerFacilityOrigin", label: "Container facility — origin", type: "boolean", description: "Can load containers onto a train. Offered as a contract origin for container freight.", showIf: (values) => Boolean(values.hasFacility), }, { name: "hasContainerFacilityDestination", label: "Container facility — destination", type: "boolean", description: "Can receive containers off a train. Offered as a contract destination for container freight.", showIf: (values) => Boolean(values.hasFacility), }, { name: "hasBulkFacilityOrigin", label: "Bulk facility — origin", type: "boolean", description: "Can load bulk cargo onto a train. Offered as a contract origin for bulk freight.", showIf: (values) => Boolean(values.hasFacility), }, { name: "hasBulkFacilityDestination", label: "Bulk facility — destination", type: "boolean", description: "Can receive bulk cargo off a train. Offered as a contract destination for bulk freight.", showIf: (values) => Boolean(values.hasFacility), }, { 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, // Category tabs — each filters server-side by appliesTo / trigger. listTabs: [ // "All" and every shape tab show customer rates only — a shipping line's // negotiated price is its own list, not an extra row in the standard one. { key: "all", label: "All", filters: { isShippingLineRate: "false" } }, { key: "shipping-line", label: "Shipping line", filters: { isShippingLineRate: "true" }, }, { key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } }, { key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } }, { key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } }, { key: "trucking", label: "First / Last mile", filters: { appliesTo: "FIRST_MILE,LAST_MILE", isShippingLineRate: "false" }, }, { key: "customs", label: "Customs clearance", filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" }, }, { key: "ethiopian-customs", label: "Ethiopian customs", filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" }, }, { key: "return", label: "Container return", filters: { trigger: "WITH_RETURN", isShippingLineRate: "false" }, }, { key: "surcharges", label: "Surcharges", filters: { appliesTo: "OTHER", isShippingLineRate: "false", trigger: "HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL", }, }, ], columns: [ // Blank on a standard customer rate; the owning carrier on a line rate. { id: "shippingLineCompany", header: "Shipping line", accessorKey: "shippingLineCompany", format: "entityLabel", }, { id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" }, { id: "trigger", header: "Trigger", accessorKey: "trigger" }, // Base freight is priced per leg, so the route is what tells two otherwise // identical rates apart. Surcharges have no leg and render as "—". { id: "originYard", header: "From", accessorKey: "originYard", format: "entityLabel" }, { id: "destinationYard", header: "To", accessorKey: "destinationYard", format: "entityLabel", }, { id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" }, { id: "rateUnit", header: "Unit", accessorKey: "rateUnit" }, // Container last-mile distance bands; blank for every other rate shape. { id: "minKm", header: "From km", accessorKey: "minKm", format: "number" }, { id: "maxKm", header: "To km", accessorKey: "maxKm", format: "number" }, { id: "status", header: "Status", accessorKey: "status", format: "rateStatus" }, ], formFields: [ // ── Shipping line rate ──────────────────────────────────────────────── // Flipping this on replaces the whole customer form: the only question // is which line, and the shape questions follow once it is answered. { name: "isShippingLineRate", label: "Shipping line rate", type: "boolean", description: "Price this rate for one shipping line's own bookings instead of for every customer. A line rate replaces the standard rate on that lane — it does not add to it.", // The owner is part of a rate's identity, so switching an existing rate // between customer and line pricing would silently re-target every // booking that prices off it. Create a new rate instead. disabledOnEdit: true, getInitialValue: (record) => Boolean(record.shippingLineCompanyId), }, { name: "shippingLineCompanyId", label: "Shipping line", type: "select", required: true, placeholder: "Which shipping line this rate is for", description: "Only this line's bookings price off this rate. A lane the line has no rate for is blocked at booking rather than falling back to the customer price.", disabledOnEdit: true, showIf: isShippingLineRate, }, // What the line is buying. Asked only after a line is picked, so the form // stays a single question until then. { name: "shippingLineRateKind", label: "Rate type", type: "select", required: true, options: SHIPPING_LINE_RATE_KINDS, placeholder: "Base freight or a surcharge?", showIf: hasShippingLine, // Not stored: base freight carries trigger ALWAYS, a surcharge anything else. getInitialValue: (record) => !record.trigger || record.trigger === "ALWAYS" ? "BASE" : "SURCHARGE", }, // Container vs bulk — the line form asks this directly instead of folding // it into `appliesTo` the way the customer form does. { name: "shippingLineCargoKind", label: "Cargo kind", type: "select", required: true, options: SHIPPING_LINE_CARGO_KINDS, placeholder: "Is this rate for containers or bulk?", showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "BASE", getInitialValue: (record) => record.appliesTo === "BULK" ? "BULK" : "CONTAINER", }, { 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.", // Derived from the two questions above on a shipping-line rate. showIf: (v) => !isShippingLineRate(v), }, // ── 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"] }, showIf: (v) => !isShippingLineRate(v), }, // The same trigger list for a shipping-line surcharge — a line incurs the // same charges a customer does (hazard, reefer, demurrage …), just at its // own negotiated price. { name: "trigger", label: "Surcharge trigger", type: "select", required: true, options: RATE_TRIGGERS, placeholder: "What makes this surcharge apply?", showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE", }, // ── Trade direction — Bulk & Container base freight, plus the directed // surcharges (customs clearance, cancellation, lashing, fuel; empty- // container return, which is import-only for now so export is not // offered) ───────────────────────────────────────────────────────────── { name: "tradeDirection", label: "Trade direction", type: "select", required: true, optionsFromValues: (v: Record) => String(v.appliesTo ?? "") === "OTHER" && String(v.trigger ?? "") === "WITH_RETURN" ? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT") : String(v.appliesTo ?? "") === "OTHER" && String(v.trigger ?? "") === "FUEL" ? FUEL_TRADE_DIRECTIONS : TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"), showIf: (v) => !isShippingLineRate(v) && (["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) || (String(v.appliesTo ?? "") === "OTHER" && [ "CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION", "WITH_RETURN", "LASHING", "FUEL", ].includes(String(v.trigger ?? "")))), }, // Shipping lines only ever ship import — the export leg is sold through // the customer's contract — so the direction is stated, not asked. Shown // as a locked field rather than hidden so the lane the yard pickers are // filtered by is visible. { name: "tradeDirection", label: "Trade direction", type: "select", required: true, options: [{ label: "Import", value: "IMPORT" }], description: "Shipping line rates are import-only.", disabled: true, // No defaultValue: field names repeat across form variants and the // seeded initial value is shared, so defaulting here would pre-select // Import on the customer form's own direction field too. computeValue // pins IMPORT on submit and locks the input regardless. computeValue: () => "IMPORT", showIf: hasShippingLine, }, // ── Cargo kind — customs clearance and the cancellation fee are priced // separately for containers (one rate per container type) and bulk (one // rate per commodity) ────────────────────────────────────────────────── { name: "cargoKind", label: "Cargo kind", type: "select", required: true, options: INTERCITY_KINDS, placeholder: "Is this fee for containers or bulk?", description: "Container fees are set per container type; bulk fees per commodity. Customs: container per box or wagon, bulk per ton or wagon. Cancellation: per wagon.", showIf: (v) => v.appliesTo === "OTHER" && isCargoKindTrigger(v), // Not a stored column: a container fee carries its containerTypeId, a // bulk fee its cargoTypeId. getInitialValue: (record) => record.containerTypeId ? "CONTAINER" : "BULK", }, // ── Container type — a container fee names the type it covers ───────── { name: "containerTypeId", label: "Container type", type: "select", required: true, placeholder: "Which container type this fee covers", showIf: (v) => v.appliesTo === "OTHER" && isCargoKindTrigger(v) && v.cargoKind === "CONTAINER", }, // ── Bulk cargo type — the bulk fee names its commodity ──────────────── { name: "cargoTypeId", label: "Bulk cargo type", type: "select", required: true, placeholder: "Which bulk commodity this fee covers", showIf: (v) => v.appliesTo === "OTHER" && isCargoKindTrigger(v) && v.cargoKind === "BULK", }, // ── Cargo type — a fuel rate names the commodity it covers (different // commodities price differently on the same lane) ────────────────────── { name: "cargoTypeId", label: "Cargo type", type: "select", required: true, placeholder: "Which cargo type this fuel rate covers", description: "Fuel is charged for bookings of this cargo type (needs “Charge fuel fee” enabled on the cargo type).", showIf: (v) => v.appliesTo === "OTHER" && v.trigger === "FUEL", }, // ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf // commodity (specific wins over the commodity-wide catch-all) ────────── { name: "cargoTypeId", label: "Bulk cargo type", type: "select", optional: true, placeholder: "All lashing commodities (optional)", description: "Leave empty to cover every lashing commodity; a commodity-specific rate wins over the catch-all.", showIf: (v) => v.appliesTo === "OTHER" && v.trigger === "LASHING", }, // ── Cargo kind — Intercity only (import/export get it from appliesTo) ─ { name: "intercityKind", label: "Cargo type", type: "select", required: true, options: INTERCITY_KINDS, placeholder: "Is this rate for containers or bulk?", description: "Intercity prices containers and bulk differently — pick which this covers.", showWhen: { field: "appliesTo", equals: ["INTERCITY"] }, // Not a stored column: an existing rate records its kind in the rateType // the API derived (INTERCITY_BULK / INTERCITY_CONTAINER). getInitialValue: (record) => record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER", }, // ── Last mile — two calculation modes ───────────────────────────────── // Bulk bills per ton per km (price = tons × km × rate); Container bills // per km, banded by distance range with one rate row per container type // per band (price = km × rate × quantity). { name: "lastMileMode", label: "Calculation mode", type: "select", required: true, options: [ { label: "Bulk (per ton per km)", value: "BULK" }, { label: "Container (per km, distance-banded)", value: "CONTAINER" }, ], description: "Bulk: price = tons × km × rate. Container: price = km × band rate × quantity, one rate per container type per distance band.", showWhen: { field: "appliesTo", equals: ["LAST_MILE"] }, // Not a stored column: the mode is recorded in the unit the API keeps. getInitialValue: (record) => record.rateUnit === "PER_TON_KM" ? "BULK" : "CONTAINER", }, { name: "containerTypeId", label: "Container type", type: "select", required: true, placeholder: "Which container type this band prices", description: "20ft and 40ft price differently — one rate per type per band.", showIf: (v) => v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", }, { name: "minKm", label: "From km", type: "number", required: true, placeholder: "0", description: "Band start (inclusive). Use 0 for the first band.", showIf: (v) => v.appliesTo === "LAST_MILE" && (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, { name: "maxKm", label: "To km", type: "number", optional: true, placeholder: "Leave empty for no upper limit", description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.", showIf: (v) => v.appliesTo === "LAST_MILE" && (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, { name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES, showWhen: { field: "appliesTo", equals: ["LAST_MILE"] }, // Birr is the norm for domestic trucking; USD stays selectable. defaultValue: "ETB", getInitialValue: (record) => String(record.currency ?? "ETB"), }, // ── Distance tiers (create only — the page swaps this for the single // From/To/value fields when editing an existing band row). Each tier // becomes its own rate row, so every band keeps edit/delete/approval. ── { name: "tiers", label: "Distance tiers", type: "tierList", required: true, description: "One rate per distance range — the rate value is per km (container mode) or per ton per km (bulk mode). To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.", showIf: (v) => v.appliesTo === "LAST_MILE" && (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, // ── Container type — Container freight, container-kind intercity, and // the empty-container return surcharge (20ft vs 40ft price differently) ─ { name: "containerTypeId", label: "Container type", type: "select", optional: true, placeholder: "Select container type (optional)", showIf: (v) => !isShippingLineRate(v) && (v.appliesTo === "CONTAINER" || (v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") || (v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")), }, // Container type for a shipping-line base-freight rate. Required here, // unlike the customer form's optional catch-all: a line negotiates a // price per box size, so an unscoped line rate has no meaning. { name: "containerTypeId", label: "Container type", type: "select", required: true, placeholder: "Which container type this rate covers", showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "BASE" && v.shippingLineCargoKind === "CONTAINER", }, // ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─ { name: "cargoTypeId", label: "Bulk cargo type", type: "select", optional: true, placeholder: "Select bulk commodity (optional)", showIf: (v) => !isShippingLineRate(v) && (v.appliesTo === "BULK" || (v.appliesTo === "INTERCITY" && v.intercityKind === "BULK")), }, // Bulk commodity for a shipping-line base-freight rate. Its unit of // measure decides the rate unit offered below — a counted commodity // (PER_ITEM) prices per item where a weighed one prices per ton. { name: "cargoTypeId", label: "Bulk cargo type", type: "select", required: true, placeholder: "Which bulk commodity this rate covers", showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "BASE" && v.shippingLineCargoKind === "BULK", }, // ── The leg this rate prices — base freight only ────────────────────── // Options are narrowed to the countries the direction allows (import // starts in Djibouti, export in Ethiopia, intercity stays in Ethiopia); // see RuleEngineResourcePage, which injects the yard lists. { name: "originYardId", label: "Origin yard", type: "select", required: true, placeholder: "Where the leg starts", showIf: isRouteScopedRate, }, { name: "destinationYardId", label: "Destination yard", type: "select", required: true, placeholder: "Where the leg ends", showIf: isRouteScopedRate, }, { name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD", showIf: (v) => v.appliesTo !== "LAST_MILE", }, // Last-mile rates carry their own currency (birr or dollar) and the // value is a per-km / per-ton·km price, so no hardcoded USD suffix. { name: "rateValue", label: "Rate value", type: "number", required: true, description: "Container mode: price per km for this band. Bulk mode: price per ton per km.", showWhen: { field: "appliesTo", equals: ["LAST_MILE"] }, }, // 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. Last mile derives its unit from the // calculation mode instead. { name: "rateUnit", label: "Rate unit", type: "select", required: true, optionsFromValues: rateUnitOptions, description: "Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).", showIf: (v) => String(v.trigger ?? "") !== "OVERWEIGHT" && String(v.appliesTo ?? "") !== "LAST_MILE", }, // ── Base liters — per-liter fuel rates only ──────────────────────────── { name: "baseLiters", label: "Base (liters)", type: "number", required: true, placeholder: "e.g. 100", description: "Liters the surcharge covers — price = base liters × rate value, charged once per booking.", showIf: (v) => v.appliesTo === "OTHER" && v.trigger === "FUEL" && v.rateUnit === "PER_LITER", }, ], }, { 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, // Replaced at render time with live IAM position types (+ legacy values). options: LEGACY_APPROVAL_ROLES, }, { name: "actionLabel", label: "Action label", type: "text", required: true }, { name: "blocksRole", label: "Blocks role", type: "select", optional: true, // Replaced at render time with live IAM position types (+ legacy values). options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...LEGACY_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), permission: ruleEngineViewKey(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;