mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
1499 lines
58 KiB
TypeScript
1499 lines
58 KiB
TypeScript
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<string, unknown>) => 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<string, unknown>) => { 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<string, unknown>) => 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<string, unknown>) => 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<RuleEngineNavCategory, string> = {
|
||
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<string, unknown>) =>
|
||
values.isShippingLineRate === true;
|
||
|
||
/** A shipping-line rate whose owning line has been chosen — the rest unlocks. */
|
||
const hasShippingLine = (values: Record<string, unknown>) =>
|
||
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<string, unknown>) =>
|
||
["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<string, unknown>) =>
|
||
// 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<string, unknown>) =>
|
||
["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<string, unknown>,
|
||
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, unknown>) =>
|
||
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<RuleEngineResourceSlug, RuleEngineResourceConfig>;
|
||
|
||
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;
|