Merge branch 'dev' into freight/feat/chat-app

This commit is contained in:
Nathnael
2026-07-17 11:01:39 +00:00
96 changed files with 3252 additions and 578 deletions

View File

@@ -18,6 +18,7 @@ import {
Send,
Settings,
ShieldCheck,
Settings2,
Ship,
SlidersHorizontal,
Train,
@@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <LayoutDashboard />,
},
{
label: "Staff",
href: "/user-management",
icon: <Users />,
},
{
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
},
{
label: "Contracts",
@@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
{
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
// Operations hub: clearance-document review for contracts WITHOUT
// customs clearing (contract-level for one-time, per-booking for general).
{
@@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
},
{
label: "Payments",
href: "/dashboard/payments",
@@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
],
},
{
title: "Operations",
// title: "Port & Terminal",
items: [
{
label: "Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
label: "Operations",
icon: <Settings />,
children: [
{
label: "Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
// {
// label: "Self-Clearance Review",
// href: "/dashboard/contracts/ops-clearance",
// icon: <ShieldCheck />,
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
// },
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
icon: <Ship />,
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
icon: <Ship />,
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
label: "Fleet Management",
icon: <Truck />,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
{
title: "Fleet Management",
items: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: FREIGHT_PERMS.fleet.view,
},
children: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
title: "Port & Terminal",
items: [
{
label: "Imports",
href: "/dashboard/import-warehouse",
@@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
],
},
],
},
{
title: "Warehouse Management",
items: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
label: "Warehouse Management",
icon: <Container />,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
children: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
},
],
},
],
},
{
title: "Administration",
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "File settings",
@@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "Configuration",
href: "/dashboard/configuration",
@@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
{
label: "Staff",
href: "/user-management",
icon: <Users />,
},
],
},
];
@@ -599,7 +599,10 @@ const findActiveSidebarLabel = (
): string | undefined => {
const path = pathname.toLowerCase();
const candidates = flattenSidebarItems(sections)
.map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() }))
.map(({ href, label }) => ({
label,
href: href.split("?")[0].toLowerCase(),
}))
.sort((a, b) => b.href.length - a.href.length);
return candidates.find(
@@ -674,10 +677,7 @@ const App = () => {
<Routes>
<Route path="/auth" element={<LoginPage />} />
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route
path="um/set-password"
element={<SetPassword />}
/>
<Route path="um/set-password" element={<SetPassword />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>

View File

@@ -280,13 +280,20 @@ export function InvoiceStatusBadge({
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
* Rejecting captures a note the customer sees so they can fix and reapply.
*
* `locked` (customer hasn't submitted onboarding) withholds the review decision
* only — there's no application to judge yet, and the API rejects the call
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
* an already-active profile is still managable.
*/
export function ProfileApprovalActions({
profileId,
status,
locked = false,
}: {
profileId: string;
status: ProfileStatus;
locked?: boolean;
}) {
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
@@ -346,6 +353,18 @@ export function ProfileApprovalActions({
</Modal>
);
// Pending/rejected are the two states awaiting a reviewer's decision the
// exact pair the API gates on until the customer submits.
if (locked && (status === "pending" || status === "rejected")) {
return (
<Tooltip label="Available once the customer submits their onboarding application">
<Text size="xs" c="dimmed" fs="italic">
Awaiting submission
</Text>
</Tooltip>
);
}
if (status === "pending") {
return (
<>

View File

@@ -263,6 +263,14 @@ const FleetFormDialog = ({
return map;
}, [fields]);
// Emptying one of these means "unset the column", so it submits an explicit
// null instead of being dropped from the payload like other empty fields.
const clearableByName = useMemo(() => {
const map: Record<string, boolean> = {};
fields.forEach((f) => (map[f.name] = Boolean(f.clearable)));
return map;
}, [fields]);
const handleSubmit = () => {
// Hard gate: a driver record cannot be saved until its identity is verified
// with Fayda. Mirrored server-side in DriversService.
@@ -271,11 +279,17 @@ const FleetFormDialog = ({
return;
}
if (!validate()) return;
// Derived fields are never edited, so form state for them can be stale (or
// seeded from the record) — recompute before building the payload.
const submitted: Record<string, unknown> = { ...values };
fields.forEach((field) => {
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
});
const payload = Object.fromEntries(
Object.entries(values)
Object.entries(submitted)
.map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "")
return [key, undefined];
if (value === FLEET_SELECT_NONE || value === "" || value == null)
return [key, clearableByName[key] ? null : undefined];
if (fieldTypeByName[key] === "number") {
const num = Number(value);
return [key, Number.isNaN(num) ? undefined : num];
@@ -294,6 +308,23 @@ const FleetFormDialog = ({
// only by verification and never hand-edited.
const isDisabled = Boolean(field.disabled || field.faydaLocked);
// Computed from other fields (e.g. the import run implied by the export
// run) — read-only, and recomputed here rather than read from form state.
if (field.derivedValue) {
return (
<TextInput
key={field.name}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={field.derivedValue(values)}
readOnly
variant="filled"
error={error}
/>
);
}
if (field.type === "radio") {
return (
<Radio.Group
@@ -319,6 +350,8 @@ const FleetFormDialog = ({
<Select
key={field.name}
label={field.label}
description={field.description}
placeholder={field.placeholder}
data={field.options ?? []}
value={
value == null || value === ""
@@ -332,6 +365,7 @@ const FleetFormDialog = ({
}
error={error}
searchable
clearable={field.clearable}
disabled={selectOptionsLoading || isDisabled}
rightSection={
selectOptionsLoading ? (

View File

@@ -92,7 +92,9 @@ const FreightSidebar = ({
walk(item.children, key);
});
};
sections.forEach((section) => walk(section.items, section.title));
sections.forEach((section, i) =>
walk(section.items, section?.title ?? "" + i++),
);
return acc;
}, [sections, isHrefActive, branchActive]);
@@ -126,6 +128,7 @@ const FreightSidebar = ({
opened={isOpen}
classNames={navClassNames(active)}
onClick={() => toggle(key)}
childrenOffset="sm"
rightSection={
<Box
component="span"
@@ -142,7 +145,7 @@ const FreightSidebar = ({
size={16}
className="text-edr-muted transition-transform duration-200"
style={{
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
transform: isOpen ? "rotate(-180deg)" : "rotate(0deg)",
}}
/>
</Box>
@@ -166,6 +169,7 @@ const FreightSidebar = ({
active={active}
component={Link}
classNames={navClassNames(active)}
onClick={onClose}
to={item.href!}
/>
);
@@ -177,19 +181,21 @@ const FreightSidebar = ({
() =>
sections.map((section) => (
<Box key={section.title}>
<Text
size="xs"
tt="uppercase"
px="sm"
mb={6}
className={"text-edr-muted!"}
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
{section.title}
</Text>
{section.title && (
<Text
size="xs"
tt="uppercase"
px="sm"
mb={6}
className={"text-edr-muted!"}
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
{section.title}
</Text>
)}
<Stack gap={2}>
{section.items.map((item, i) =>
renderItem(item, itemKey(section.title, item, i)),
renderItem(item, itemKey(section.title ?? "" + i, item, i)),
)}
</Stack>
</Box>
@@ -257,7 +263,7 @@ const FreightSidebar = ({
px="sm"
pb="md"
>
<Stack gap="lg">{renderedSections}</Stack>
<Stack gap="md">{renderedSections}</Stack>
</AppShell.Section>
</AppShell.Navbar>
);

View File

@@ -12,7 +12,7 @@ export interface SidebarItem {
export interface SidebarSection {
/** Section label shown above a group of nav items (e.g. "Main menu"). */
title: string;
title?: string;
items: SidebarItem[];
/** When true, section title uses muted grey instead of dark text. */
mutedTitle?: boolean;

View File

@@ -1,5 +1,6 @@
import { Freight } from "@edr/types";
import {
Badge,
Button,
Checkbox,
Group,
@@ -24,11 +25,19 @@ export default function AvailableWagonsPanel({
yardLabel,
onAssign,
assigning,
exportTrainNumber,
importTrainNumber,
}: AvailableWagonsPanelProps) {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [runOnly, setRunOnly] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
// The train's own run, e.g. "8001-8002" — only offered when the train has one.
const runLabel = exportTrainNumber
? `${exportTrainNumber}${importTrainNumber ? `-${importTrainNumber}` : ""}`
: null;
const wagonsQuery = useQuery(
api.wagons.list.queryOptions({
input: {
@@ -42,10 +51,23 @@ export default function AvailableWagonsPanel({
const q = search.trim().toLowerCase();
return (wagonsQuery.data ?? []).filter((wagon) => {
if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
// Rostered to this train's run — match on the export run, which fixes the
// import run anyway.
if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false;
if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
return true;
});
}, [wagonsQuery.data, search, typeFilter]);
}, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]);
const runMatchCount = useMemo(
() =>
exportTrainNumber
? (wagonsQuery.data ?? []).filter(
(w) => w.exportTrainNumber === exportTrainNumber,
).length
: 0,
[wagonsQuery.data, exportTrainNumber],
);
const typeOptions = useMemo(() => {
const byId = new Map<string, string>();
@@ -112,6 +134,15 @@ export default function AvailableWagonsPanel({
/>
</Group>
{runLabel ? (
<Checkbox
size="sm"
label={`Only wagons on this train's run (${runLabel}) — ${runMatchCount} here`}
checked={runOnly}
onChange={(e) => setRunOnly(e.currentTarget.checked)}
/>
) : null}
{wagons.length ? (
<Checkbox
size="sm"
@@ -151,9 +182,24 @@ export default function AvailableWagonsPanel({
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
radius="sm"
variant="light"
color={
wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"
}
>
{wagon.exportTrainNumber}
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
@@ -183,4 +229,8 @@ export interface AvailableWagonsPanelProps {
yardLabel?: string | null;
onAssign: (wagonIds: string[]) => void;
assigning: boolean;
/** This train's odd EXPORT run — drives the "only this run" filter. */
exportTrainNumber?: string | null;
/** This train's even IMPORT run — label only; the export run does the matching. */
importTrainNumber?: string | null;
}

View File

@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
import { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast";
import { EXPORT_TRAIN_OPTIONS, importRunFor } from "@/constants/trainRuns";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
@@ -26,10 +27,6 @@ const parseError = (error: unknown, fallback: string) => {
return fallback;
};
// Run-number parity carries the trade direction: odd = export, even = import.
const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
/**
* Step one of the Train Builder: pick the yard it is being assembled in and
* couple at least two locomotives from that yard. The train code is assigned by
@@ -59,6 +56,12 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
setLocomotiveIds([]);
}, [yardId]);
// The import run is fixed by the export run, so it tracks it rather than
// being entered by hand (and clears back to empty when the export is cleared).
useEffect(() => {
setImportTrainNumber(importRunFor(exportTrainNumber));
}, [exportTrainNumber]);
useEffect(() => {
if (!opened) {
setExportTrainNumber("");
@@ -78,9 +81,11 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
});
return;
}
if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) {
// Both numbers come from the fixed run pairs, so parity cannot be wrong —
// only "nothing picked" is reachable here.
if (!exportTrainNumber || !importTrainNumber) {
toast({
title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)",
title: "Pick an export train number (e.g. 8001) — the import run follows it",
variant: "destructive",
});
return;
@@ -133,31 +138,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
maxLength={100}
/>
<Group grow>
<TextInput
<Select
label="Export train number"
description="Odd — Ethiopia → Djibouti runs"
placeholder="e.g. 8001"
value={exportTrainNumber}
onChange={(e) => setExportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
exportTrainNumber && !isOddNumber(exportTrainNumber)
? "Must be numeric and odd"
: undefined
}
data={EXPORT_TRAIN_OPTIONS}
value={exportTrainNumber || null}
onChange={(value) => setExportTrainNumber(value ?? "")}
searchable
clearable
/>
{/* Fixed by the export run — derived, never typed. */}
<TextInput
label="Import train number"
description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002"
value={importTrainNumber}
onChange={(e) => setImportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
importTrainNumber && !isEvenNumber(importTrainNumber)
? "Must be numeric and even"
: undefined
}
readOnly
variant="filled"
/>
</Group>
<Select

View File

@@ -65,7 +65,10 @@ export default function AdjustConsistModal({
}
}, [opened]);
// Live projection: gross = cargo + tare of (consist trims + adds).
// Live projection: gross = cargo + tare of (consist trims + adds), plus
// the schedule's wagon-slot picture — the consist IS the booking capacity
// (weight/length only bind while assembling the consist), so trims/adds
// move the FULL line in real time.
const projection = useMemo(() => {
if (!data) return null;
const removed = new Set(removeIds);
@@ -79,8 +82,11 @@ export default function AdjustConsistModal({
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
const gross = round2(data.totals.cargoTons + tare);
const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length;
const cap = data.scheduleCapacity;
const freeSlots = cap ? wagonCount - cap.allocatedWagons : null;
return {
wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
wagonCount,
tare: round2(tare),
gross,
length: round2(length),
@@ -93,16 +99,33 @@ export default function AdjustConsistModal({
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
overLength:
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
slots:
cap && freeSlots != null
? {
allocated: cap.allocatedWagons,
free: freeSlots,
pct:
wagonCount > 0
? Math.round((cap.allocatedWagons / wagonCount) * 100)
: null,
isFullNow: cap.bookingWindowStatus === "FULL",
willBeFull: freeSlots <= 0,
overAllocated: freeSlots < 0,
willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0,
}
: null,
};
}, [data, removeIds, addIds]);
const hasChanges = removeIds.length > 0 || addIds.length > 0;
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
const handleSubmit = async () => {
if (!removeIds.length && !addIds.length) return;
try {
await adjust.mutateAsync({
const result = await adjust.mutateAsync({
scheduleId,
payload: {
...(addIds.length ? { addWagonIds: addIds } : {}),
@@ -114,6 +137,18 @@ export default function AdjustConsistModal({
removeIds.length && addIds.length ? ", " : ""
}${addIds.length ? `${addIds.length} added` : ""}`,
});
// Schedule-impact warnings from the API: window reopened / now FULL /
// consist trimmed below what bookings already hold.
for (const warning of result.warnings ?? []) {
toast({
title: "Schedule capacity",
description: warning,
duration: 8000,
...(warning.includes("over capacity")
? { variant: "destructive" as const }
: {}),
});
}
setRemoveIds([]);
setAddIds([]);
} catch (err) {
@@ -169,8 +204,57 @@ export default function AdjustConsistModal({
over={projection?.overLength ?? false}
/>
</Grid.Col>
{projection?.slots ? (
<Grid.Col span={12}>
<LimitGauge
label="Booking slots — the consist is the schedule's capacity"
detail={`${projection.slots.allocated} of ${projection.wagonCount} projected wagon slot(s) held by bookings${
projection.slots.free > 0
? `${projection.slots.free} free`
: projection.slots.free === 0
? " — none free (FULL)"
: ""
}`}
pct={projection.slots.pct}
over={projection.slots.overAllocated}
/>
</Grid.Col>
) : null}
</Grid>
{projection?.slots?.isFullNow && !hasChanges ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This schedule is FULL all {projection.wagonCount} wagon slots are
taken. You can still edit the train: coupling wagons adds capacity
and reopens booking; trimming free wagons keeps it FULL.
</Alert>
) : null}
{hasChanges && projection?.slots?.overAllocated ? (
<Alert color="red" icon={<AlertTriangle size={16} />}>
This change leaves {-projection.slots.free} booked wagon(s) without
a slot bookings already hold {projection.slots.allocated} of the{" "}
{projection.wagonCount} remaining. You can apply it, but couple
wagons back or free bookings before departure.
</Alert>
) : null}
{hasChanges &&
projection?.slots &&
!projection.slots.overAllocated &&
projection.slots.willBeFull &&
!projection.slots.isFullNow ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This change takes the last free wagon slot the schedule becomes
FULL and stops accepting bookings.
</Alert>
) : null}
{hasChanges && projection?.slots?.willReopen ? (
<Alert color="blue" icon={<AlertTriangle size={16} />}>
This schedule is currently FULL applying frees{" "}
{projection.slots.free} wagon slot(s) and reopens its booking
window.
</Alert>
) : null}
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Stack gap="xs">

View File

@@ -5,7 +5,6 @@ import type { ContainerUnitRow } from '@/types/trainScheduling';
function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] {
const units: ContainerUnitRow[] = [];
const containersPerWagon = sizeFt >= 40 ? 1 : 2;
const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5;
for (let i = 0; i < quantity; i++) {
units.push({
@@ -18,7 +17,6 @@ function makeUnits(containerType: string, sizeFt: number, quantity: number): Con
label: `${containerType} ${i + 1}/${quantity}`,
grossWeightTons: 25,
sizeFt,
wagonsPerUnit,
containersPerWagon,
teuSlots: sizeFt >= 40 ? 2 : 1,
});

View File

@@ -167,6 +167,7 @@ export const QUERY_KEYS = {
orderList: (resource: RuleEngineResourceSlug | string) =>
["rule-engine", "order-list", resource] as const,
priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const,
rateChanges: ["rule-engine", "rate-changes"] as const,
},
OVERVIEW: {

View File

@@ -0,0 +1,35 @@
/**
* EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The
* even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing
* an export number fully determines the import one.
*
* Run numbers are always 4 digits (8401, never 84001). Pairs are listed out
* rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks
* the convention stays correct here.
*
* Mirrors RUN_WAGONS/IMPORT_RUN in the API's SeedWagonRunNumbers migration —
* keep the two in sync when runs are added or retired.
*/
export const TRAIN_RUN_PAIRS: Record<string, string> = {
"8001": "8002",
"8101": "8102",
"8201": "8202",
"8301": "8302",
"8401": "8402",
"8501": "8502",
"8601": "8602",
"8701": "8702",
"8801": "8802",
"8901": "8902",
"9001": "9002",
};
/** Selectable export runs, in run order. */
export const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({
label: run,
value: run,
}));
/** The import run implied by an export run; empty string when unset/unknown. */
export const importRunFor = (exportRun: unknown): string =>
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? "";

View File

@@ -7,6 +7,7 @@ import {
ruleEngineService,
type RuleEngineListParams,
type SubmitPriorityRuleChangePayload,
type SubmitRateChangePayload,
} from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import type {
@@ -318,6 +319,71 @@ export const usePriorityRuleWorkflow = (
return { pending, submit, approve, reject };
};
/**
* Approval workflow for edits to LIVE rates. The live rate keeps its current
* value until a change is approved, so the rates list is invalidated on every
* outcome — including reject, which restores the row's "no pending" state.
*/
export const useRateChangeWorkflow = (
enabled: boolean,
onErrorMessage?: (message: string) => void,
) => {
const qc = useQueryClient();
const showError = (err: unknown, fallback: string) => {
const raw = (err as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
const message = (Array.isArray(raw) ? raw.join(", ") : raw) || fallback;
if (onErrorMessage) onErrorMessage(message);
else toast.error(message);
};
const pending = useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges,
queryFn: () => ruleEngineService.listRateChanges("PENDING"),
enabled,
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges });
await invalidateRuleEngineList(qc, "rates");
};
const submit = useMutation({
mutationFn: (payload: SubmitRateChangePayload) =>
ruleEngineService.submitRateChange(payload),
onSuccess: async () => {
toast.success(
"Change submitted for approval — the rate keeps its current value until approved",
);
await invalidate();
},
onError: (err) => showError(err, "Failed to submit rate change"),
});
const approve = useMutation({
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
ruleEngineService.approveRateChange(id, decisionNote),
onSuccess: async () => {
toast.success("Rate change approved — the new rate is now live");
await invalidate();
},
onError: (err) => showError(err, "Failed to approve rate change"),
});
const reject = useMutation({
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
ruleEngineService.rejectRateChange(id, decisionNote),
onSuccess: async () => {
toast.success("Rate change rejected — the rate keeps its current value");
await invalidate();
},
onError: (err) => showError(err, "Failed to reject rate change"),
});
return { pending, submit, approve, reject };
};
export const useRateWorkflow = () => {
const qc = useQueryClient();

View File

@@ -446,6 +446,21 @@ export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
}
/**
* Deciding a filed change — a step above `manage`, which only lets a staff
* member propose one. Only resources with an approval workflow have it.
*/
export function ruleEngineApproveKey(slug: "rates"): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`;
}
export function canApproveRuleEngineChange(
user: AuthUser | null | undefined,
slug: "rates",
): boolean {
return hasPermission(user, ruleEngineApproveKey(slug));
}
export function canAccessRuleEngineResource(
user: AuthUser | null | undefined,
slug: RuleEngineResourceSlug,

View File

@@ -61,7 +61,6 @@ interface RefContainerType {
name: string;
code: string;
is_reefer?: boolean;
wagons_per_unit?: number;
}
interface RefContainerGroup {
size: string;

View File

@@ -1,5 +1,6 @@
import {
ActionIcon,
Alert,
Anchor,
Badge,
Box,
@@ -22,6 +23,7 @@ import {
Download,
Eye,
FileText,
Hourglass,
IdCard,
LayoutGrid,
Package,
@@ -60,6 +62,7 @@ import type {
CustomerDocument,
CustomerPayment,
} from "@/types/customer";
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
@@ -166,6 +169,13 @@ export default function CustomerDetailPage() {
);
const paidCurrency = payments[0]?.currency ?? "ETB";
// The company row is created on the wizard's first click, so a draft reaches
// this page with a placeholder name/TIN. `stillOnboarding` drives the banner
// and badge; `canReview` gates the approve/reject buttons and mirrors the
// API's rule exactly, so no button is offered that the server would reject.
const stillOnboarding = company ? isOnboardingDraft(company) : false;
const canReview = company ? hasSubmittedOnboarding(company) : true;
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
() => [
{
@@ -273,11 +283,12 @@ export default function CustomerDetailPage() {
<ProfileApprovalActions
profileId={row.original.id}
status={row.original.status}
locked={!canReview}
/>
),
},
],
[view],
[view, canReview],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
@@ -602,7 +613,13 @@ export default function CustomerDetailPage() {
meta={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} />
{stillOnboarding ? (
<Badge color="gray" variant="light" size="sm" radius="sm">
Onboarding in progress
</Badge>
) : (
<CompanyStatusBadge status={company.status} />
)}
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
@@ -631,6 +648,21 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
{stillOnboarding && (
<Alert
color="gray"
variant="light"
radius="md"
icon={<Hourglass size={18} />}
title="This customer hasn't submitted their application yet"
>
They're still filling in the onboarding wizard, so the details
below are an unfinished draft — the company name and TIN are
placeholders until they reach those steps. Role profiles become
reviewable once the application is submitted.
</Alert>
)}
<ChangeRequestReview company={company} />
<KpiStrip
@@ -642,10 +674,16 @@ export default function CustomerDetailPage() {
color: "edr-green",
},
{
label: "Pending approval",
value: company.companyProfiles.filter(
(p) => p.status === "pending",
).length,
// A draft's profiles are all `pending` by construction, which
// would read as a review backlog that doesn't exist yet.
label: stillOnboarding
? "Awaiting submission"
: "Pending approval",
value: stillOnboarding
? "—"
: company.companyProfiles.filter(
(p) => p.status === "pending",
).length,
icon: IdCard,
color: "yellow",
},

View File

@@ -16,6 +16,7 @@ import {
Building2,
CheckCircle2,
Clock,
Hourglass,
Mail,
Phone,
RefreshCw,
@@ -36,6 +37,7 @@ import {
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import {
DataTable,
DataTableFooter,
@@ -43,22 +45,39 @@ import {
type ColumnDef,
} from "@edr/ui-common";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
* review, so it excludes drafts — a company row exists from the onboarding
* wizard's first click and would otherwise pad the review queue. Those drafts
* get their own view instead of disappearing, so staff can still chase them.
*/
type CustomerView = "all" | "pending" | "onboarding" | "active";
const VIEW_FILTERS: Record<
CustomerView,
{ status?: CompanyStatus; onboardingCompleted?: boolean }
> = {
all: {},
pending: { status: "pending", onboardingCompleted: true },
onboarding: { onboardingCompleted: false },
active: { status: "active" },
};
export default function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
const [view, setView] = useState<CustomerView>("all");
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
...VIEW_FILTERS[view],
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
[pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
@@ -114,6 +133,17 @@ export default function CustomersPage() {
id: "status",
header: "Status",
cell: ({ row }) => {
// A draft's profiles are all `pending` by construction, so the
// "N pending" review hint would be a lie until they submit.
if (isOnboardingDraft(row.original)) {
return (
<Tooltip label="Customer is still filling in the onboarding wizard">
<Badge color="gray" variant="light" size="sm" radius="sm">
Onboarding
</Badge>
</Tooltip>
);
}
const pending = (row.original.companyProfiles ?? []).filter(
(p) => p.status === "pending",
).length;
@@ -206,6 +236,12 @@ export default function CustomersPage() {
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
{
label: "Onboarding",
value: stats?.onboarding ?? "—",
icon: Hourglass,
color: "gray",
},
{
label: "Blacklisted",
value: stats?.blacklisted ?? "—",
@@ -243,14 +279,15 @@ export default function CustomersPage() {
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
value={view}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
setView(v as CustomerView);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
/>

View File

@@ -1,5 +1,6 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
import { EXPORT_TRAIN_OPTIONS, importRunFor } from "@/constants/trainRuns";
import { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles";
import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers";
@@ -40,6 +41,20 @@ export interface FleetResourceColumn {
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
/**
* Read-only field whose value is computed from the other fields rather than
* typed. Rendered non-editable and recomputed on every change, so the stored
* form value for this field is never trusted — the function is the source of
* truth at both render and submit.
*/
derivedValue?: (values: Record<string, unknown>) => string;
/**
* Field can be emptied back to NULL. Empty values are normally dropped from
* the payload (so a PATCH leaves them untouched); a clearable field instead
* submits an explicit `null`, which is what actually unsets the column. Also
* renders a clear button on a `select`.
*/
clearable?: boolean;
/**
* Field is owned by the Fayda identity — populated only by verification and
* never hand-edited. Rendered disabled in the form.
@@ -118,6 +133,7 @@ const WAGON_STATUS_OPTIONS = [
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
@@ -261,16 +277,49 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "currentYard",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
searchKeys: [
"wagonNumber",
"wagonTypeId",
"trainId",
"exportTrainNumber",
"importTrainNumber",
"status",
"currentYardId",
],
columns: [
// Tare weight and payload capacity are not wagon columns — they belong to the
// wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages).
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
// Unset on a wagon that is not on a run — renders as a dimmed dash.
{ id: "exportTrainNumber", header: "Export train no.", accessorKey: "exportTrainNumber", format: "code" },
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
// Run numbers are optional — a wagon sits in the fleet unassigned to any
// run until an operator picks an export run. The import run is fixed by
// that choice, so it is derived rather than typed.
{
name: "exportTrainNumber",
label: "Export train number",
type: "select",
description: "Odd — Ethiopia → Djibouti runs",
placeholder: "e.g. 8001",
options: EXPORT_TRAIN_OPTIONS,
clearable: true,
},
{
name: "importTrainNumber",
label: "Import train number",
type: "text",
description: "Even — Djibouti → Ethiopia runs",
placeholder: "e.g. 8002",
derivedValue: (values) => importRunFor(values.exportTrainNumber),
// Follows the export run to NULL when that is cleared.
clearable: true,
},
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
@@ -278,6 +327,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
exportTrainNumber: "",
importTrainNumber: "",
wagonNumber: "",
wagonTypeId: "",
currentYardId: "",

View File

@@ -0,0 +1,247 @@
import { useState } from "react";
import {
Badge,
Button,
Card,
Collapse,
Group,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import type { UseMutationResult } from "@tanstack/react-query";
import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react";
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
/** Field labels for the diff — anything not listed falls back to the raw key. */
const FIELD_LABELS: Record<string, string> = {
rateValue: "Rate",
currency: "Currency",
rateUnit: "Unit",
appliesTo: "Applies to",
trigger: "Trigger",
tradeDirection: "Direction",
containerTypeId: "Container type",
cargoTypeId: "Cargo type",
};
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const fmtValue = (field: string, value: unknown): string => {
if (value === null || value === undefined || value === "") return "—";
if (field === "rateValue") {
const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString();
}
return String(value).replace(/_/g, " ");
};
/** "Ocean freight · 40HC" — what rate this change targets. */
const rateSummary = (r: RateChangeRequest): string => {
const rate = (r.rate ?? {}) as Record<string, unknown>;
const parts = [
rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null,
rate.appliesTo ? String(rate.appliesTo) : null,
rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null,
].filter(Boolean);
return parts.join(" · ") || "Rate";
};
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
const headline = (r: RateChangeRequest): string | null => {
if (!("rateValue" in r.payload)) return null;
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
const before = fmtValue("rateValue", r.previousValues.rateValue);
const after = fmtValue("rateValue", r.payload.rateValue);
return `${before}${after}${currency ? ` ${currency}` : ""}`;
};
type Decide = UseMutationResult<
RateChangeRequest,
unknown,
{ id: string; decisionNote?: string }
>;
interface RateApprovalsSectionProps {
requests: RateChangeRequest[];
/** Whether this user holds the rates approve permission. */
canDecide: boolean;
approve: Decide;
reject: Decide;
}
/**
* Pending edits to LIVE rates. Each row is a before→after diff: the left value
* is what pricing charges right now and keeps charging until someone approves.
* Rendered above the rates table.
*/
const RateApprovalsSection = ({
requests,
canDecide,
approve,
reject,
}: RateApprovalsSectionProps) => {
const [openId, setOpenId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
if (requests.length === 0) return null;
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
return (
<Card withBorder radius="md" padding="md" mb="md">
<Group gap={8} mb={4}>
<Clock size={16} />
<Text fw={700}>Pending rate changes</Text>
<Badge variant="light" color="yellow">
{requests.length}
</Badge>
</Group>
<Text size="xs" c="dimmed" mb="sm">
Each rate below still charges its current value. Nothing changes until approved.
</Text>
<Stack gap={8}>
{requests.map((r) => {
const isOpen = openId === r.id;
const fields = Object.keys(r.payload);
const summaryLine = headline(r);
// Only the row being decided shows a spinner — the mutation's
// isPending is shared across every row.
const busy = decidingId === r.id;
return (
<Card key={r.id} withBorder radius="md" padding="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color="blue" radius="sm">
update
</Badge>
<Text size="sm" fw={600} truncate>
{rateSummary(r)}
</Text>
</Group>
{summaryLine ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" c="dimmed" td="line-through">
{fmtValue("rateValue", r.previousValues.rateValue)}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={700} c="edr-green">
{fmtValue("rateValue", r.payload.rateValue)}
</Text>
<Text size="sm" c="dimmed">
{String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
)}
</Text>
</Group>
) : null}
<Group gap={6}>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
<Button
size="compact-xs"
variant="subtle"
onClick={() => setOpenId(isOpen ? null : r.id)}
>
{isOpen ? "Hide details" : "See all changes"}
</Button>
</Group>
</Stack>
{canDecide ? (
<Group gap={8} wrap="nowrap">
<Button
size="compact-sm"
variant="subtle"
color="red"
leftSection={<XCircle size={14} />}
loading={busy && reject.isPending}
disabled={busy && approve.isPending}
onClick={() =>
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
}
>
Reject
</Button>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
loading={busy && approve.isPending}
disabled={busy && reject.isPending}
onClick={() =>
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
}
>
Approve &amp; apply
</Button>
</Group>
) : (
<Tooltip label="You need the rates approve permission to decide this">
<Badge variant="light" color="gray" radius="sm">
Awaiting approver
</Badge>
</Tooltip>
)}
</Group>
<Collapse in={isOpen}>
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
{fields.map((field) => (
<Group key={field} gap={8} wrap="nowrap">
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
{FIELD_LABELS[field] ?? field}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue(field, r.previousValues[field])}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{fmtValue(field, r.payload[field])}
</Text>
</Group>
))}
{canDecide ? (
<Textarea
mt={4}
size="xs"
autosize
minRows={2}
label="Decision note (optional)"
placeholder="Shown to the requester with your decision"
value={notes[r.id] ?? ""}
onChange={(e) =>
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
}
/>
) : null}
</Stack>
</Collapse>
</Card>
);
})}
</Stack>
</Card>
);
};
export default RateApprovalsSection;

View File

@@ -1,5 +1,8 @@
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import {
canAccessRuleEngineResource,
canApproveRuleEngineChange,
} from "@/lib/permissions";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
@@ -11,14 +14,16 @@ import {
Modal,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { Plus } from "lucide-react";
import { Clock, Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
@@ -37,6 +42,7 @@ import {
useLiveRateOptions,
useWagonTypeOptions,
usePriorityRuleWorkflow,
useRateChangeWorkflow,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -51,6 +57,7 @@ import {
getRuleEngineResource,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
import type { RuleEngineRecord } from "@/types/rule-engine";
import {
DataTable,
@@ -145,6 +152,23 @@ const RuleEngineResourcePage = () => {
chainOpen && config?.slug === "approval-rules",
);
// A LIVE rate is what pricing charges, so editing one files a change request
// instead of mutating: the rate keeps its current value until an approver
// applies the change. DRAFT rates still edit directly.
const isRates = config?.slug === "rates";
const [rateError, setRateError] = useState<string | null>(null);
const rateChangeWorkflow = useRateChangeWorkflow(
Boolean(isRates && canView),
setRateError,
);
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
/** rateId → its pending change, for the row badge. */
const pendingByRateId = useMemo(() => {
const map = new Map<string, RateChangeRequest>();
for (const r of rateChangeWorkflow.pending.data ?? []) map.set(r.rateId, r);
return map;
}, [rateChangeWorkflow.pending.data]);
// Priority rules never mutate directly: changes are filed for approval and a
// pending queue renders above the table. Validation errors (range collision,
// gap, ceiling) surface in a modal so the text is impossible to miss.
@@ -306,7 +330,27 @@ const RuleEngineResourcePage = () => {
id: col.id,
header: col.header,
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatCell(row.original[col.accessorKey], col.format),
cell: ({ row }) => {
const cell = formatCell(row.original[col.accessorKey], col.format);
// On the rate column, show the proposed value under the live one — the
// live value stays the headline because it is what still gets charged.
if (!isRates || col.accessorKey !== "rateValue") return cell;
const change = pendingByRateId.get(String(row.original.id));
if (!change || change.payload.rateValue === undefined) return cell;
return (
<Stack gap={0}>
{cell}
<Tooltip label="Awaiting approval — this rate still charges its current value">
<Group gap={4} wrap="nowrap">
<Clock size={11} color="var(--mantine-color-orange-6)" />
<Text size="xs" c="orange.7" fw={600}>
{Number(change.payload.rateValue).toLocaleString()} pending
</Text>
</Group>
</Tooltip>
</Stack>
);
},
}));
base.push({
@@ -357,6 +401,8 @@ const RuleEngineResourcePage = () => {
}, [
canManage,
config,
isRates,
pendingByRateId,
submit,
handleApproveRate,
handleMoveOrder,
@@ -400,6 +446,21 @@ const RuleEngineResourcePage = () => {
currency: "USD",
trigger: isSurcharge ? values.trigger : "ALWAYS",
};
// Editing a LIVE rate files a change request — the rate keeps charging
// its current value until an approver applies it. DRAFT rates fall
// through to the normal update below.
if (editing?.id && editing.status === "LIVE") {
rateChangeWorkflow.submit.mutate(
{ rateId: String(editing.id), update: payload },
{
onSuccess: () => {
setFormOpen(false);
setEditing(null);
},
},
);
return;
}
} else if (isPriorityRules) {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
@@ -483,6 +544,31 @@ const RuleEngineResourcePage = () => {
/>
) : null}
{isRates ? (
<RateApprovalsSection
requests={rateChangeWorkflow.pending.data ?? []}
canDecide={canApproveRates}
approve={rateChangeWorkflow.approve}
reject={rateChangeWorkflow.reject}
/>
) : null}
<Modal
opened={rateError != null}
onClose={() => setRateError(null)}
title="Cannot save rate change"
centered
>
<Text size="sm" c="red">
{rateError}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={() => setRateError(null)}>
Close
</Button>
</Group>
</Modal>
<Modal
opened={priorityError != null}
onClose={() => setPriorityError(null)}

View File

@@ -384,7 +384,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Max wagon count",
type: "number",
required: true,
description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
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" },
@@ -477,6 +477,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "country", header: "Country", accessorKey: "country" },
{
id: "hasFacility",
header: "Facility",
accessorKey: "hasFacility",
format: "boolean",
},
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
activeColumn,
],
@@ -489,6 +495,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
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: "isActive", label: "Active", type: "boolean" },
],
},

View File

@@ -1,20 +1,14 @@
/**
* Client mirror of the backend's contiguous-range rules for priority configs
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
* currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
* range always starts at the lowest uncovered wagon count. The backend
* re-validates on submit AND on approval; this only drives the form prefill.
* currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next
* range always starts at the lowest uncovered wagon count. There is no upper
* ceiling. The backend re-validates on submit AND on approval; this only
* drives the form prefill.
*/
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
export const PRIORITY_RANGE_CAPS: Record<PriorityRuleType, number> = {
WAGON: 50,
CURRENCY: 35,
CUSTOMS: 15,
};
export interface PriorityRangeRule {
id?: unknown;
type?: unknown;
@@ -23,10 +17,13 @@ export interface PriorityRangeRule {
maxWagonCount?: unknown;
}
const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"];
/**
* Where the next range for `type` (+`currency`) must start, excluding
* `excludeId` (the rule being edited). Null when the chain already covers
* 1..cap — no further rule fits.
* `excludeId` (the rule being edited). Null only when `type` is not yet a
* known priority rule type — the chain itself is unbounded, so a next start
* always exists.
*/
export function nextPriorityRangeStart(
rules: PriorityRangeRule[],
@@ -34,8 +31,7 @@ export function nextPriorityRangeStart(
currency: string | null | undefined,
excludeId?: string,
): number | null {
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
if (!cap) return null;
if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null;
const scoped = rules
.filter(
@@ -56,5 +52,5 @@ export function nextPriorityRangeStart(
if (r.min > next) break; // gap before this rule — fill it first
next = Math.max(next, r.max + 1);
}
return next > cap ? null : next;
return next;
}

View File

@@ -255,6 +255,8 @@ export default function TrainBuilderDetailPage() {
<AvailableWagonsPanel
yardId={yard?.id ?? ""}
yardLabel={yard?.label}
exportTrainNumber={composition.exportTrainNumber}
importTrainNumber={composition.importTrainNumber}
assigning={assignWagons.isPending}
onAssign={(wagonIds) =>
void withToast(

View File

@@ -50,11 +50,7 @@ export default function TrainSchedulingGlobalRulesPage() {
// refilled) must not silently save as 0. Collect the numeric payload and
// reject if any value is blank or NaN.
const fields: (keyof TrainSchedulingGlobalRules)[] = [
"maxTrainLengthMeters",
"maxTrainWeightTons",
"maxWagonsPerTrain",
"max20ftContainerWeightTons",
"max20ftPairWeightDiffTons",
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
@@ -98,32 +94,6 @@ export default function TrainSchedulingGlobalRulesPage() {
<Card maw={720}>
<Stack gap="md">
<NumberInput
label="Max train length (m)"
description="Sum of all wagon lengths must not exceed this"
value={form.maxTrainLengthMeters ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
/>
<NumberInput
label="Max train weight (T)"
description="Total container and bulk cargo weight must not exceed this"
value={form.maxTrainWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
/>
<NumberInput
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
@@ -136,38 +106,6 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
<NumberInput
label="Max 20ft container weight (T)"
description="Each individual 20ft container gross weight limit"
value={form.max20ftContainerWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({
...current,
max20ftContainerWeightTons: value,
}))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0.001}
disabled={loading}
/>
<NumberInput
label="Max 20ft pair weight difference (T)"
description="When two 20ft containers share a wagon, |weight1 weight2| must not exceed this"
value={form.max20ftPairWeightDiffTons ?? ""}
onChange={(value) =>
setForm((current) => ({
...current,
max20ftPairWeightDiffTons: value,
}))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0}
disabled={loading}
/>
</Stack>
</Card>

View File

@@ -185,6 +185,7 @@ import { trainService, type Train } from "./trains.service";
import {
trainBuilderService,
type AdjustConsistPayload,
type AdjustConsistResult,
type AvailableTrain,
type BuildTrainPayload,
type BuiltTrainListFilters,
@@ -338,7 +339,7 @@ export const api = {
adjustConsist: endpoint<
{ scheduleId: string; payload: AdjustConsistPayload },
ScheduleConsist
AdjustConsistResult
>(
"train-scheduling",
"adjust-consist",

View File

@@ -48,6 +48,30 @@ export interface SubmitPriorityRuleChangePayload {
update?: Record<string, unknown>;
}
/** Approval workflow for edits to LIVE rates — a DRAFT rate still edits directly. */
const RATE_CHANGES_BASE = "/rate-change-requests";
export interface RateChangeRequest {
id: string;
rateId: string;
rate?: RuleEngineRecord | null;
/** Changed fields only. */
payload: Record<string, unknown>;
/** What those same fields were when the change was filed. */
previousValues: Record<string, unknown>;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedByUserId: string | null;
decidedByUserId: string | null;
decidedAt: string | null;
decisionNote: string | null;
createdAt: string;
}
export interface SubmitRateChangePayload {
rateId: string;
update: Record<string, unknown>;
}
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
@@ -306,6 +330,44 @@ export const ruleEngineService = {
return unwrap(response.data) as PriorityRuleChangeRequest;
},
/** Propose a change to a LIVE rate — it stays at its current value until approved. */
submitRateChange: async (
payload: SubmitRateChangePayload,
): Promise<RateChangeRequest> => {
const response = await client.post(RATE_CHANGES_BASE, payload);
return unwrap(response.data) as RateChangeRequest;
},
listRateChanges: async (
status?: RateChangeRequest["status"],
): Promise<RateChangeRequest[]> => {
const response = await client.get(RATE_CHANGES_BASE, {
params: status ? { status } : undefined,
});
const body = unwrap(response.data) as unknown;
return Array.isArray(body) ? (body as RateChangeRequest[]) : [];
},
approveRateChange: async (
id: string,
decisionNote?: string,
): Promise<RateChangeRequest> => {
const response = await client.post(`${RATE_CHANGES_BASE}/${id}/approve`, {
decisionNote,
});
return unwrap(response.data) as RateChangeRequest;
},
rejectRateChange: async (
id: string,
decisionNote?: string,
): Promise<RateChangeRequest> => {
const response = await client.post(`${RATE_CHANGES_BASE}/${id}/reject`, {
decisionNote,
});
return unwrap(response.data) as RateChangeRequest;
},
getApprovalChain: async (
requiresDirectorApproval = true,
): Promise<RuleEngineRecord[]> => {

View File

@@ -235,6 +235,18 @@ export interface ScheduleConsist {
occurredAt: string;
}>;
editable: boolean;
/**
* Wagon-slot picture of the schedule: the consist IS the booking capacity
* (weight/length only bind while building the consist), so the dialog can
* project FULL / reopen / over-allocation live. Null on legacy schedules.
*/
scheduleCapacity: {
maxWagons: number;
allocatedWagons: number;
remainingSlots: number;
overAllocatedBy: number;
bookingWindowStatus: string | null;
} | null;
}
export interface AdjustConsistPayload {
@@ -242,6 +254,9 @@ export interface AdjustConsistPayload {
removeWagonIds?: string[];
}
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -275,7 +290,7 @@ export const trainBuilderService = {
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
/** Permanently trim/add wagons on the schedule's built train. */
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
apiClient.post<ScheduleConsist>(
apiClient.post<AdjustConsistResult>(
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload,
),

View File

@@ -28,6 +28,10 @@ export interface Wagon {
status: Freight.WagonStatus;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
exportTrainNumber?: string | null;
/** Even IMPORT run (Djibouti → Ethiopia); always the export run + 1. */
importTrainNumber?: string | null;
notes?: string;
}

View File

@@ -146,10 +146,38 @@ export interface Company {
website?: string | null;
attributes?: Record<string, unknown> | null;
companyProfiles: CompanyProfile[];
/**
* Whether the customer submitted their onboarding application. A company row
* is created on the wizard's first click, so a `pending` company with this
* false is a half-filled draft — not reviewable. Staff-created companies are
* always true. Undefined on endpoints that don't load external profiles.
*/
onboardingCompleted?: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Whether the customer has submitted their onboarding application. Mirrors the
* API's review gate (`setCompanyProfileStatus`): until this is true, a role
* awaiting a decision cannot be approved or rejected. Companies loaded without
* external profiles (`undefined`) are treated as submitted — absence of the
* flag must not lock staff out.
*/
export function hasSubmittedOnboarding(company: Company): boolean {
return company.onboardingCompleted !== false;
}
/**
* A pristine draft: still `pending` and never submitted, so its name/TIN are
* placeholders and there is nothing to review. Drives presentation only — the
* approval gate is `hasSubmittedOnboarding`, which also covers the (corrupted)
* case of a company activated before that gate existed.
*/
export function isOnboardingDraft(company: Company): boolean {
return company.status === "pending" && !hasSubmittedOnboarding(company);
}
/** Query parameters for the company list. */
export interface CompanyListFilter {
page: number;
@@ -158,6 +186,8 @@ export interface CompanyListFilter {
type?: CompanyType;
kind?: CompanyKind;
status?: CompanyStatus;
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
onboardingCompleted?: boolean;
}
/** Standard paginated list envelope (matches the bookings service shape). */
@@ -170,7 +200,10 @@ export interface PaginatedCompanies {
export interface CompanyStats {
total: number;
active: number;
/** Submitted applications awaiting review. Excludes drafts. */
pending: number;
/** Self-registered companies still working through the onboarding wizard. */
onboarding: number;
suspended: number;
blacklisted: number;
}

View File

@@ -72,7 +72,6 @@ export interface ContainerUnitRow {
label: string;
grossWeightTons: number;
sizeFt?: number;
wagonsPerUnit?: number;
containersPerWagon?: number;
teuSlots?: number;
containerNumber?: string | null;
@@ -113,11 +112,7 @@ export interface DeferredBookingRow {
export interface TrainSchedulingGlobalRules {
id: string;
maxTrainLengthMeters: number;
maxTrainWeightTons: number;
maxWagonsPerTrain: number;
max20ftContainerWeightTons: number;
max20ftPairWeightDiffTons: number;
importWindowLeadDays: number;
exportBookingLeadHours: number;
windowOpenHour: number;