booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

@@ -21,6 +21,7 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
@@ -35,13 +36,10 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage";
import {
CargoesCrudPage,
ContainersCrudPage,
LocomotivesCrudPage,
TrainMasterDataPage,
WagonsCrudPage,
} from "./pages/fleet/FleetCrudPages";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
@@ -72,6 +70,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/operations/train-scheduling",
icon: <Train />,
},
{
label: "Train Schedules v2",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
},
],
},
{
@@ -159,7 +162,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: getCategorySidebarChildren("configuration"),
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
},
],
},
{
label: "Rules",
@@ -235,21 +244,28 @@ const App = () => {
<Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainMasterDataPage />} />
<Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route
path="operations/train-scheduling-v2"
element={<TrainScheduleV2ListPage />}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={<TrainScheduleV2DetailPage />}
/>
<Route path="routes" element={<RoutesPage />} />
<Route path="locomotives" element={<LocomotivesCrudPage />} />
<Route path="trains" element={<TrainMasterDataPage />} />
<Route path="locomotives" element={<FleetResourcePage />} />
<Route path="trains" element={<FleetResourcePage />} />
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsCrudPage />} />
<Route path="containers" element={<ContainersCrudPage />} />
<Route path="cargoes" element={<CargoesCrudPage />} />
<Route path="wagons" element={<FleetResourcePage />} />
<Route path="containers" element={<FleetResourcePage />} />
<Route path="cargoes" element={<FleetResourcePage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
@@ -265,6 +281,10 @@ const App = () => {
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/train-scheduling-rules"
element={<TrainSchedulingGlobalRulesPage />}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route

View File

@@ -7,6 +7,7 @@ import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isAllocateAction,
isContractNavAction,
listRowHasActions,
type BookingActionContext,
@@ -20,12 +21,14 @@ interface BookingActionsMenuProps {
className?: string;
/** Suppresses table row navigation after menu/dialog close (click-through). */
onSuppressRowClick?: () => void;
onAllocateBooking?: () => void;
}
export function BookingActionsMenu({
row,
variant = "table",
onSuppressRowClick,
onAllocateBooking,
}: BookingActionsMenuProps) {
const navigate = useNavigate();
const { user } = useAuth();
@@ -34,6 +37,7 @@ export function BookingActionsMenu({
paymentCurrency: row.paymentCurrency,
reference: row.reference,
approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus,
};
const flow = useBookingActionDialog(row.id, context);
@@ -46,6 +50,8 @@ export function BookingActionsMenu({
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else {
flow.openAction(action);
}

View File

@@ -1,10 +1,13 @@
import { useState } from "react";
import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
import { SectionCard } from "./detail/SectionCard";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
type Mutations = ReturnType<typeof useBookingMutations>;
@@ -18,6 +21,7 @@ interface BookingActionsToolbarProps {
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
const row = toBookingListRow(booking);
const { status } = booking;
const [allocateOpen, setAllocateOpen] = useState(false);
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
@@ -98,7 +102,11 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
<Text size="xs" c="dimmed">
Confirm each step before it is applied.
</Text>
<BookingActionsMenu row={row} variant="toolbar" />
<BookingActionsMenu
row={row}
variant="toolbar"
onAllocateBooking={() => setAllocateOpen(true)}
/>
</Stack>
</SectionCard>
@@ -118,6 +126,14 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
</Button>
</SectionCard>
)}
{canAllocateBooking(booking) ? (
<AllocateBookingWizard
booking={booking}
opened={allocateOpen}
onClose={() => setAllocateOpen(false)}
/>
) : null}
</Stack>
);
}

View File

@@ -0,0 +1,225 @@
import { useMemo, useState } from "react";
import { ArrowRight, Building2, Package } from "lucide-react";
import {
Accordion,
Badge,
Button,
Checkbox,
Group,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
import type { BookingListRow } from "@/types/booking";
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
function BookingQueueRow({
booking,
selected,
disabled,
onToggle,
}: {
booking: BookingListRow;
selected: boolean;
disabled: boolean;
onToggle: () => void;
}) {
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
>
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs">
<Package size={14} />
<Text fw={600} size="sm">{booking.reference}</Text>
{booking.isGovernment ? (
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
Government
</Badge>
) : null}
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
{booking.schedulingStatus ? (
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
<Group gap={6}>
<Text size="xs">{booking.originLabel}</Text>
<ArrowRight size={12} />
<Text size="xs">{booking.destinationLabel}</Text>
</Group>
<Group gap="sm">
<BookingPriorityBadge score={booking.priorityScore} />
{booking.serviceTypeLabel ? (
<Text size="xs" c="dimmed">
{booking.serviceTypeLabel}
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
</Text>
) : null}
</Group>
</Stack>
</Group>
);
}
export function OperationsBookingQueue({
bookings,
isLoading,
onAllocate,
}: {
bookings: BookingListRow[];
isLoading?: boolean;
onAllocate: (bookingIds: string[]) => void;
}) {
const { government, commercial } = useMemo(
() => groupBookingsForOperationsQueue(bookings),
[bookings],
);
const [govSelected, setGovSelected] = useState<string[]>([]);
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
const allocatable = (row: BookingListRow) =>
row.status === "PAID" &&
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
const govSelection = govSelected.length
? govSelected
: government.filter(allocatable).map((b) => b.id);
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
const existing = selectedByBucket[bucketKey];
if (existing) return existing;
return bucketBookings.filter(allocatable).map((b) => b.id);
};
const toggleGov = (bookingId: string) => {
setGovSelected((prev) => {
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
return base.includes(bookingId)
? base.filter((id) => id !== bookingId)
: [...base, bookingId];
});
};
const toggleBucket = (bucketKey: string, bookingId: string) => {
setSelectedByBucket((prev) => {
const current = prev[bucketKey] ?? [];
const next = current.includes(bookingId)
? current.filter((id) => id !== bookingId)
: [...current, bookingId];
return { ...prev, [bucketKey]: next };
});
};
if (isLoading) {
return <Text size="sm" c="dimmed">Loading operations queue</Text>;
}
if (!government.length && !commercial.length) {
return (
<Text size="sm" c="dimmed">
No PAID bookings ready to allocate.
</Text>
);
}
return (
<Stack gap="lg">
{government.length > 0 ? (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" mb="md">
<Stack gap={2}>
<Title order={5}>Government priority</Title>
<Text size="xs" c="dimmed">
Served first not grouped by 3-hour window
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{govSelection.length} selected</Badge>
<Button
size="compact-sm"
color="violet"
disabled={!govSelection.length}
onClick={() => onAllocate(govSelection)}
>
Allocate
</Button>
</Group>
</Group>
<Stack gap="sm">
{government.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={govSelection.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleGov(booking.id)}
/>
))}
</Stack>
</Paper>
) : null}
{commercial.length > 0 ? (
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
{commercial.map((bucket) => {
const selected = bucketSelection(bucket.key, bucket.bookings);
return (
<Accordion.Item key={bucket.key} value={bucket.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Stack gap={2}>
<Text fw={600} size="sm">{bucket.label}</Text>
<Text size="xs" c="dimmed">
{bucket.bookings.length} commercial booking
{bucket.bookings.length === 1 ? "" : "s"}
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{selected.length} selected</Badge>
<Button
size="compact-sm"
color="green"
disabled={!selected.length}
onClick={(e) => {
e.stopPropagation();
onAllocate(selected);
}}
>
Allocate
</Button>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{bucket.bookings.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={selected.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleBucket(bucket.key, booking.id)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : null}
</Stack>
);
}

View File

@@ -0,0 +1,97 @@
import { Link } from "react-router-dom";
import { ArrowRight, ExternalLink } from "lucide-react";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import type { BookingListRow } from "@/types/booking";
import { DataTable, type ColumnDef } from "@edr/ui-common";
export function OperationsScheduledBookings({
bookings,
isLoading,
}: {
bookings: BookingListRow[];
isLoading?: boolean;
}) {
const columns: ColumnDef<BookingListRow>[] = [
{
id: "reference",
header: "Booking",
cell: ({ row }) => (
<Stack gap={2}>
<Group gap={6}>
<Text fw={600} size="sm">{row.original.reference}</Text>
{row.original.isGovernment ? (
<Badge color="violet" size="xs">Government</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">{row.original.customerLabel}</Text>
</Stack>
),
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<Group gap={6}>
<Text size="sm">{row.original.originLabel}</Text>
<ArrowRight size={12} />
<Text size="sm">{row.original.destinationLabel}</Text>
</Group>
),
},
{
id: "scheduled",
header: "Scheduled",
cell: ({ row }) => (
<Text size="sm">{String(row.original.scheduledDate).slice(0, 16)}</Text>
),
},
{
id: "status",
header: "Scheduling",
cell: ({ row }) =>
row.original.schedulingStatus ? (
<SchedulingStatusBadge status={row.original.schedulingStatus} />
) : (
<Badge variant="light"></Badge>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<Group gap="xs">
<Button
component={Link}
to={`/dashboard/booking-requests/${row.original.id}`}
variant="light"
size="compact-sm"
>
View booking
</Button>
{row.original.trainScheduleId ? (
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${row.original.trainScheduleId}`}
variant="subtle"
size="compact-sm"
leftSection={<ExternalLink size={14} />}
>
Train schedule
</Button>
) : null}
</Group>
),
},
];
return (
<DataTable
columns={columns}
data={bookings}
status={isLoading ? "loading" : "success"}
emptyMessage="No bookings currently assigned to a train schedule"
/>
);
}

View File

@@ -4,6 +4,7 @@ import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { detailStyles, formatDate } from "./booking-detail.styles";
@@ -52,7 +53,15 @@ export function BookingRequestHero({
</Title>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="yellow.8">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
{booking.nextStep && (
<Box maw={520}>

View File

@@ -1,47 +0,0 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useContainers, useAssignContainerToWagon } from './use-containers';
import { useToast } from '@/hooks/use-toast';
import { Plus } from 'lucide-react';
export function AssignContainerDialog({ wagonId }: { wagonId: string }) {
const [open, setOpen] = useState(false);
const [containerId, setContainerId] = useState('');
const [position, setPosition] = useState<number>();
const { data: containers } = useContainers();
const assign = useAssignContainerToWagon();
const { toast } = useToast();
const available = containers?.filter(c => c.status === 'AVAILABLE' && !c.wagonId);
const handleAssign = async () => {
if (!containerId) return;
await assign.mutateAsync({ containerId, wagonId, position });
toast({ title: 'Assigned', description: 'Container placed on wagon.' });
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />Assign Container</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Assign Container to Wagon</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Container</Label><Select value={containerId} onValueChange={setContainerId}><SelectTrigger><SelectValue placeholder="Select container" /></SelectTrigger><SelectContent>{available?.map(c => <SelectItem key={c.id} value={c.id}>{c.containerNumber}</SelectItem>)}</SelectContent></Select></div>
<div><Label>Position (optional)</Label><Input type="number" value={position ?? ''} onChange={e => setPosition(parseInt(e.target.value) || undefined)} /></div>
<Button onClick={handleAssign} disabled={assign.isPending}>Assign</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,125 +0,0 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { useCargoTypes } from './use-cargo-types';
import { useCargoMutations } from './use-cargoes';
import { Loader2 } from 'lucide-react';
interface Cargo {
id: string;
cargoNumber: string;
cargoTypeId: string;
weight: number;
remarks?: string;
}
interface CargoFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
cargo?: Cargo | null;
onSuccess?: () => void;
}
export default function CargoFormDialog({
open,
onOpenChange,
cargo,
onSuccess,
}: CargoFormDialogProps) {
const { data: cargoTypes } = useCargoTypes();
const { createCargo, updateCargo } = useCargoMutations();
const [formData, setFormData] = useState<Partial<Cargo>>({
cargoNumber: '',
cargoTypeId: '',
weight: 0,
remarks: '',
});
useEffect(() => {
if (cargo) {
setFormData(cargo);
} else {
setFormData({
cargoNumber: '',
cargoTypeId: '',
weight: 0,
remarks: '',
});
}
}, [cargo, open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (cargo?.id) {
updateCargo.mutate(
{ id: cargo.id, data: formData },
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
);
} else {
createCargo.mutate(formData, {
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
});
}
};
const isLoading = createCargo.isPending || updateCargo.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader><DialogTitle>{cargo ? 'Edit Cargo' : 'Create New Cargo'}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Cargo Number *</Label>
<Input value={formData.cargoNumber} onChange={e => setFormData({...formData, cargoNumber: e.target.value})} required />
</div>
<div>
<Label>Cargo Type *</Label>
<Select
value={formData.cargoTypeId || ''}
onValueChange={(val) => setFormData({ ...formData, cargoTypeId: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select cargo type..." />
</SelectTrigger>
<SelectContent>
{cargoTypes?.map((type: any) => (
<SelectItem key={type.id} value={type.id}>{type.cargo_type_name || type.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Weight (kg) *</Label>
<Input type="number" value={formData.weight} onChange={e => setFormData({...formData, weight: parseFloat(e.target.value)})} required />
</div>
<div>
<Label>Remarks</Label>
<Textarea value={formData.remarks} onChange={e => setFormData({...formData, remarks: e.target.value})} rows={3} />
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading}>{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}{cargo ? 'Update' : 'Create'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,246 +0,0 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { useContainerTypes } from './use-container-types';
import { useContainerMutations } from './use-containers';
import { Loader2 } from 'lucide-react';
interface Container {
id: string;
containerNumber: string;
containerTypeId: string;
wagonId?: string;
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
capacity: number;
weight: number;
remarks?: string;
}
interface Wagon {
id: string;
wagonNumber: string;
}
interface ContainerFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
container?: Container | null;
wagons: Wagon[];
onSuccess?: () => void;
}
export default function ContainerFormDialog({
open,
onOpenChange,
container,
wagons = [],
onSuccess,
}: ContainerFormDialogProps) {
const { data: containerTypes } = useContainerTypes();
const { createContainer, updateContainer } = useContainerMutations();
const [formData, setFormData] = useState<Partial<Container>>({
containerNumber: '',
containerTypeId: '',
status: 'AVAILABLE',
capacity: 0,
weight: 0,
remarks: '',
});
useEffect(() => {
if (container) {
setFormData(container);
} else {
setFormData({
containerNumber: '',
containerTypeId: '',
status: 'AVAILABLE',
capacity: 0,
weight: 0,
remarks: '',
});
}
}, [container, open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.containerNumber || !formData.containerTypeId) {
toast.error('Please fill in all required fields');
return;
}
if (container?.id) {
updateContainer.mutate(
{ id: container.id, data: formData },
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
);
} else {
createContainer.mutate(formData, {
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
});
}
};
const isLoading = createContainer.isPending || updateContainer.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{container ? 'Edit Container' : 'Create New Container'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="containerNumber">Container Number *</Label>
<Input
id="containerNumber"
value={formData.containerNumber || ''}
onChange={(e) =>
setFormData({ ...formData, containerNumber: e.target.value })
}
placeholder="e.g., CNT001"
required
/>
</div>
<div>
<Label htmlFor="containerTypeId">Container Type *</Label>
<Select
value={formData.containerTypeId || ''}
onValueChange={(val:any) => setFormData({ ...formData, containerTypeId: val })}
>
<SelectTrigger id="containerTypeId">
<SelectValue placeholder="Select container type..." />
</SelectTrigger>
<SelectContent>
{containerTypes?.map((type: any) => (
<SelectItem key={type.id} value={type.id}>{type.name || type.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="wagonId">Wagon (Optional)</Label>
<Select
value={formData.wagonId || 'none'}
onValueChange={(val:any) => setFormData({ ...formData, wagonId: val === 'none' ? undefined : val })}
>
<SelectTrigger id="wagonId">
<SelectValue placeholder="Select a wagon..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{wagons.map((wagon) => (
<SelectItem key={wagon.id} value={wagon.id}>
{wagon.wagonNumber}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="status">Status</Label>
<Select
value={formData.status || 'AVAILABLE'}
onValueChange={(val:any) => setFormData({ ...formData, status: val })}
>
<SelectTrigger id="status">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="AVAILABLE">Available</SelectItem>
<SelectItem value="IN_USE">In Use</SelectItem>
<SelectItem value="MAINTENANCE">Maintenance</SelectItem>
<SelectItem value="RETIRED">Retired</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="capacity">Capacity *</Label>
<Input
id="capacity"
type="number"
value={formData.capacity || ''}
onChange={(e) =>
setFormData({
...formData,
capacity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="weight">Weight (kg)</Label>
<Input
id="weight"
type="number"
value={formData.weight || ''}
onChange={(e) =>
setFormData({
...formData,
weight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{container ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,44 +0,0 @@
import { useContainersByWagon, useUnassignContainer } from './use-containers';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
import type { Container } from './container.service';
export function ContainersTable({ wagonId }: { wagonId: string }) {
const { data: containers, refetch } = useContainersByWagon(wagonId);
const unassign = useUnassignContainer();
if (!containers?.length) return <div className="text-muted-foreground">No containers assigned.</div>;
return (
<table className="w-full table-fixed">
<thead>
<tr>
<th className="text-left">Number</th>
<th className="text-left">Type</th>
<th className="text-left">Position</th>
<th className="text-left">Status</th>
<th className="text-left">Actions</th>
</tr>
</thead>
<tbody>
{containers.map((container: Container) => (
<tr key={container.id}>
<td className="py-2">{container.containerNumber}</td>
<td className="py-2">{container.containerTypeId}</td>
<td className="py-2">{container.position}</td>
<td className="py-2">{container.status}</td>
<td className="py-2">
<Button
variant="ghost"
size="icon"
onClick={() => unassign.mutateAsync(container.id).then(() => refetch())}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,15 +0,0 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const cargoTypesService = {
async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -1,19 +0,0 @@
import { api } from "../../auth/http";
export const cargoService = {
async getCargoes() {
const response = await api.get('/cargoes');
return response.data;
},
async createCargo(data: any) {
const response = await api.post('/cargoes', data);
return response.data;
},
async updateCargo(id: string, data: any) {
const response = await api.patch(`/cargoes/${id}`, data);
return response.data;
},
async deleteCargo(id: string) {
await api.delete(`/cargoes/${id}`);
},
};

View File

@@ -1,15 +0,0 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const containerTypesService = {
async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -1,31 +0,0 @@
import { api } from "../../auth/http";
export const containerService = {
async getContainers() {
const response = await api.get('/containers');
return response.data;
},
async getContainersByWagon(wagonId: string) {
const response = await api.get('/containers', { params: { wagonId } });
return response.data;
},
async createContainer(data: any) {
const response = await api.post('/containers', data);
return response.data;
},
async updateContainer(id: string, data: any) {
const response = await api.patch(`/containers/${id}`, data);
return response.data;
},
async deleteContainer(id: string) {
await api.delete(`/containers/${id}`);
},
async assignToWagon(containerId: string, wagonId: string, position?: number) {
const response = await api.post(`/containers/${containerId}/assign-wagon`, { wagonId, position });
return response.data;
},
async unassignFromWagon(containerId: string) {
const response = await api.post(`/containers/${containerId}/unassign-wagon`);
return response.data;
},
};

View File

@@ -1,12 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { cargoTypesService } from './cargo-types.service';
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
export function useCargoTypes() {
return useQuery({
queryKey: CARGO_TYPES_QUERY_KEY,
queryFn: () => cargoTypesService.getCargoTypes(),
staleTime: Infinity,
});
}

View File

@@ -1,42 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { cargoService } from './cargo.service';
import { toast } from 'sonner';
export const CARGOES_QUERY_KEY = ['cargoes'];
export function useCargoes() {
return useQuery({
queryKey: CARGOES_QUERY_KEY,
queryFn: () => cargoService.getCargoes(),
});
}
export function useCargoMutations() {
const queryClient = useQueryClient();
const createCargo = useMutation({
mutationFn: (data: any) => cargoService.createCargo(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo created successfully');
},
});
const updateCargo = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => cargoService.updateCargo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo updated successfully');
},
});
const deleteCargo = useMutation({
mutationFn: (id: string) => cargoService.deleteCargo(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo deleted successfully');
},
});
return { createCargo, updateCargo, deleteCargo };
}

View File

@@ -1,12 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { containerTypesService } from './container-types.service';
export const CONTAINER_TYPES_QUERY_KEY = ['container-types'];
export function useContainerTypes() {
return useQuery({
queryKey: CONTAINER_TYPES_QUERY_KEY,
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: Infinity,
});
}

View File

@@ -1,73 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { containerService } from './container.service';
import { toast } from 'sonner';
export const CONTAINERS_QUERY_KEY = ['containers'];
export function useContainers() {
return useQuery({
queryKey: CONTAINERS_QUERY_KEY,
queryFn: () => containerService.getContainers(),
});
}
export function useContainersByWagon(wagonId: string) {
return useQuery({
queryKey: [...CONTAINERS_QUERY_KEY, 'wagon', wagonId],
queryFn: () => containerService.getContainersByWagon(wagonId),
enabled: !!wagonId,
});
}
export function useContainerMutations() {
const queryClient = useQueryClient();
const createContainer = useMutation({
mutationFn: (data: any) => containerService.createContainer(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container created successfully');
},
});
const updateContainer = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => containerService.updateContainer(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container updated successfully');
},
});
const deleteContainer = useMutation({
mutationFn: (id: string) => containerService.deleteContainer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container deleted successfully');
},
});
return { createContainer, updateContainer, deleteContainer };
}
export function useUnassignContainer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => containerService.unassignFromWagon(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container unassigned from wagon');
},
});
}
export function useAssignContainerToWagon() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ containerId, wagonId, position }: { containerId: string; wagonId: string; position?: number }) =>
containerService.assignToWagon(containerId, wagonId, position),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container assigned to wagon');
},
});
}

View File

@@ -1,12 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { wagonTypesService } from './wagon-types.service';
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
export function useWagonTypes() {
return useQuery({
queryKey: WAGON_TYPES_QUERY_KEY,
queryFn: () => wagonTypesService.getWagonTypes(),
staleTime: Infinity,
});
}

View File

@@ -1,48 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { wagonService } from './wagon.service';
import { toast } from 'sonner';
export const WAGONS_QUERY_KEY = ['wagons'];
export function useWagons() {
return useQuery({
queryKey: WAGONS_QUERY_KEY,
queryFn: () => wagonService.getWagons(),
});
}
export function useWagonMutations() {
const queryClient = useQueryClient();
const createWagon = useMutation({
mutationFn: (data: any) => wagonService.createWagon(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon created successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to create wagon');
},
});
const updateWagon = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => wagonService.updateWagon(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon updated successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update wagon');
},
});
const deleteWagon = useMutation({
mutationFn: (id: string) => wagonService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon deleted successfully');
},
});
return { createWagon, updateWagon, deleteWagon };
}

View File

@@ -1,13 +0,0 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const wagonTypesService = {
async getWagonTypes() {
const response = await api.get<ListResponse<unknown>>('/wagon-types');
return asList(response.data);
},
};

View File

@@ -1,23 +0,0 @@
import { api } from "../../auth/http";
export const wagonService = {
async getWagons() {
const response = await api.get('/wagons');
return response.data;
},
async getWagonById(id: string) {
const response = await api.get(`/wagons/${id}`);
return response.data;
},
async createWagon(data: any) {
const response = await api.post('/wagons', data);
return response.data;
},
async updateWagon(id: string, data: any) {
const response = await api.patch(`/wagons/${id}`, data);
return response.data;
},
async deleteWagon(id: string) {
await api.delete(`/wagons/${id}`);
},
};

View File

@@ -0,0 +1,182 @@
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { Badge } from "@mantine/core";
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import FleetRecordActions from "./FleetRecordActions";
import { cardInitials, resolveFleetCardPresentation } from "./fleetCardMeta";
import { formatFleetCell } from "./fleetFormat";
import RuleEngineListFooter from "../ruleEngine/RuleEngineListFooter";
export interface FleetCardGridProps {
config: FleetResourceConfig;
rows: FleetRecord[];
status: "loading" | "error" | "success";
emptyMessage: string;
pagination: PaginationState;
pageCount: number;
totalCount: number;
onPaginationChange: OnChangeFn<PaginationState>;
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
}
const FleetCardGrid = ({
config,
rows,
status,
emptyMessage,
pagination,
pageCount,
totalCount,
onPaginationChange,
onEdit,
onRemove,
}: FleetCardGridProps) => {
const presentation = resolveFleetCardPresentation(config);
if (status === "loading") {
return (
<Text size="sm" c="dimmed" py="xl" ta="center">
Loading
</Text>
);
}
if (status === "error") {
return (
<Text size="sm" c="red" py="xl" ta="center">
Failed to load data
</Text>
);
}
if (!rows.length) {
return (
<Text size="sm" c="dimmed" py="xl" ta="center">
{emptyMessage}
</Text>
);
}
return (
<Stack gap={0}>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{rows.map((record) => {
const title = String(
(record as unknown as Record<string, unknown>)[presentation.titleKey] ?? config.entityLabel,
);
const code = presentation.codeKey
? (record as unknown as Record<string, unknown>)[presentation.codeKey]
: null;
const subtitle = presentation.subtitleKey
? (record as unknown as Record<string, unknown>)[presentation.subtitleKey]
: null;
const statusValue = presentation.statusKey
? (record as unknown as Record<string, unknown>)[presentation.statusKey]
: null;
return (
<Card
key={String((record as { id: string }).id)}
radius="lg"
padding="lg"
withBorder
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: "var(--mantine-color-green-0)",
color: "var(--mantine-color-green-7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: 14,
}}
>
{cardInitials(title)}
</div>
<Stack gap={2}>
<Text fw={600} size="sm" lineClamp={1}>
{title || "—"}
</Text>
{subtitle != null && subtitle !== "" ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{String(subtitle)}
</Text>
) : null}
</Stack>
</Group>
{code != null && code !== "" ? (
<Badge variant="light" color="blue" size="sm" radius="md">
{String(code)}
</Badge>
) : null}
</Group>
<Stack gap={6}>
{config.columns
.filter(
(col) =>
col.accessorKey !== presentation.titleKey &&
col.accessorKey !== presentation.codeKey &&
col.accessorKey !== presentation.statusKey,
)
.slice(0, 4)
.map((col) => (
<Group key={col.id} justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{col.header}
</Text>
<Text size="xs" fw={500}>
{formatFleetCell(
(record as unknown as Record<string, unknown>)[col.accessorKey],
col.format,
col.accessorKey,
)}
</Text>
</Group>
))}
{statusValue != null ? (
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
Status
</Text>
{formatFleetCell(statusValue, "statusBadge")}
</Group>
) : null}
</Stack>
<FleetRecordActions
record={record}
config={config}
layout="compact"
onEdit={onEdit}
onRemove={onRemove}
/>
</Stack>
</Card>
);
})}
</SimpleGrid>
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
itemLabel={config.entityLabel.toLowerCase() + "s"}
onPaginationChange={onPaginationChange}
/>
</Stack>
);
};
export default FleetCardGrid;

View File

@@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import {
Button,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { FLEET_SELECT_NONE, type FleetFormFieldDef } from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
fields: FleetFormFieldDef[];
initialRecord?: FleetRecord | null;
emptyValues: Record<string, unknown>;
isSubmitting: boolean;
selectOptionsLoading?: boolean;
onSubmit: (values: Record<string, unknown>) => void;
}
const buildInitialValues = (
fields: FleetFormFieldDef[],
emptyValues: Record<string, unknown>,
record?: FleetRecord | null,
): Record<string, unknown> => {
const values: Record<string, unknown> = { ...emptyValues };
if (!record) return values;
fields.forEach((field) => {
const raw = (record as unknown as Record<string, unknown>)[field.name];
if (raw === null || raw === undefined) {
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
return;
}
values[field.name] = raw;
});
return values;
};
const FleetFormDialog = ({
open,
onOpenChange,
title,
fields,
initialRecord,
emptyValues,
isSubmitting,
selectOptionsLoading,
onSubmit,
}: FleetFormDialogProps) => {
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (open) {
setValues(buildInitialValues(fields, emptyValues, initialRecord));
setErrors({});
}
}, [open, fields, emptyValues, initialRecord]);
const shortFields = useMemo(
() => fields.filter((f) => f.type !== "textarea"),
[fields],
);
const longFields = useMemo(
() => fields.filter((f) => f.type === "textarea"),
[fields],
);
const validate = () => {
const next: Record<string, string> = {};
fields.forEach((field) => {
const value = values[field.name];
const stringValue =
typeof value === "string" ? value.trim() : String(value ?? "");
if (field.required && (stringValue === "" || stringValue === FLEET_SELECT_NONE)) {
next[field.name] = `${field.label} is required`;
}
});
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
const payload = Object.fromEntries(
Object.entries(values)
.map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "") return [key, undefined];
return [key, value];
})
.filter(([, value]) => value !== undefined),
);
onSubmit(payload);
};
const renderField = (field: FleetFormFieldDef) => {
const value = values[field.name];
const error = errors[field.name];
if (field.type === "select") {
return (
<Select
key={field.name}
label={field.label}
data={field.options ?? []}
value={value == null || value === "" ? (field.noneOption ? FLEET_SELECT_NONE : null) : String(value)}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
}
error={error}
searchable
disabled={selectOptionsLoading}
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
/>
);
}
if (field.type === "number") {
return (
<NumberInput
key={field.name}
label={field.label}
value={value === "" || value == null ? "" : Number(value)}
onChange={(next) =>
setValues((current) => ({
...current,
[field.name]: next === "" ? "" : next,
}))
}
error={error}
/>
);
}
if (field.type === "textarea") {
return (
<Textarea
key={field.name}
label={field.label}
value={String(value ?? "")}
onChange={(e) =>
setValues((current) => ({ ...current, [field.name]: e.currentTarget.value }))
}
error={error}
minRows={3}
/>
);
}
return (
<TextInput
key={field.name}
label={field.label}
value={String(value ?? "")}
onChange={(e) =>
setValues((current) => ({ ...current, [field.name]: e.currentTarget.value }))
}
error={error}
/>
);
};
return (
<Modal
opened={open}
onClose={() => onOpenChange(false)}
title={<Text fw={600}>{title}</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{shortFields.map(renderField)}
</SimpleGrid>
{longFields.map(renderField)}
<Group justify="flex-end" gap="sm" mt="sm">
<Button variant="default" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button color="green" loading={isSubmitting} onClick={handleSubmit}>
Save
</Button>
</Group>
</Stack>
</Modal>
);
};
export default FleetFormDialog;

View File

@@ -0,0 +1,101 @@
import { MoreHorizontal, Pencil, Trash2, Truck } from "lucide-react";
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetRecordActionsProps {
record: FleetRecord;
config: FleetResourceConfig;
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
layout?: "row" | "compact";
}
const FleetRecordActions = ({
record,
config,
onEdit,
onRemove,
layout = "row",
}: FleetRecordActionsProps) => {
const navigate = useNavigate();
const removeLabel = config.removeActionLabel ?? "Delete";
const showDetail = Boolean(config.detailPath && "id" in record);
const handleDetail = () => {
if (!config.detailPath || !("id" in record)) return;
navigate(config.detailPath.replace(":id", String(record.id)));
};
if (layout === "compact") {
return (
<Group gap={6} wrap="nowrap" justify="flex-end">
{showDetail ? (
<Button
variant="light"
color="green"
size="compact-sm"
radius="md"
onClick={handleDetail}
leftSection={<Truck size={14} />}
>
Manage wagons
</Button>
) : null}
<Button
variant="light"
color="gray"
size="compact-sm"
radius="md"
onClick={() => onEdit(record)}
leftSection={<Pencil size={14} />}
>
Edit
</Button>
<Button
variant="light"
color="red"
size="compact-sm"
radius="md"
onClick={() => onRemove(record)}
leftSection={<Trash2 size={14} />}
>
{removeLabel}
</Button>
</Group>
);
}
return (
<Group gap={4} wrap="nowrap" justify="flex-end">
{showDetail ? (
<Tooltip label="Manage wagons">
<ActionIcon variant="subtle" color="green" size="md" radius="md" onClick={handleDetail}>
<Truck size={16} />
</ActionIcon>
</Tooltip>
) : null}
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="gray" size="md" radius="md" onClick={() => onEdit(record)}>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="md" radius="md">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item color="red" leftSection={<Trash2 size={14} />} onClick={() => onRemove(record)}>
{removeLabel}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
};
export default FleetRecordActions;

View File

@@ -0,0 +1,113 @@
import type { ReactNode } from "react";
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import { Box, Button, Group, SegmentedControl, TextInput } from "@mantine/core";
import type { FleetViewMode } from "./useFleetViewMode";
export interface FleetToolbarProps {
search?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
showSearch?: boolean;
onAdd?: () => void;
addLabel?: string;
viewMode: FleetViewMode;
onViewModeChange: (mode: FleetViewMode) => void;
/** Optional filters rendered beside search (status, freight type, etc.) */
filters?: ReactNode;
}
const FleetToolbar = ({
search = "",
onSearchChange,
searchPlaceholder = "Search…",
showSearch = true,
onAdd,
addLabel = "Add",
viewMode,
onViewModeChange,
filters,
}: FleetToolbarProps) => (
<Box w="100%">
<Group
gap="md"
justify="space-between"
align="center"
wrap="wrap"
style={{ width: "100%" }}
>
<Group
gap="sm"
align="center"
wrap="wrap"
style={{ flex: "1 1 280px", minWidth: 0 }}
>
{showSearch && onSearchChange ? (
<TextInput
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)}
leftSection={<Search size={16} />}
size="sm"
radius="lg"
style={{ flex: "1 1 200px", minWidth: 180, maxWidth: 360 }}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
) : null}
{filters ? (
<Group gap="sm" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
{filters}
</Group>
) : null}
</Group>
<Group gap="sm" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
<SegmentedControl
value={viewMode}
onChange={(value) => onViewModeChange(value as FleetViewMode)}
size="sm"
radius="lg"
color="green"
data={[
{
value: "table",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Table2 size={14} />
<span>Table</span>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<LayoutGrid size={14} />
<span>Cards</span>
</Group>
),
},
]}
styles={{
root: { background: "var(--mantine-color-gray-1)" },
}}
/>
{onAdd ? (
<Button
color="green"
radius="lg"
size="sm"
fw={600}
leftSection={<Plus size={16} />}
onClick={onAdd}
style={{ whiteSpace: "nowrap" }}
>
{addLabel}
</Button>
) : null}
</Group>
</Group>
</Box>
);
export default FleetToolbar;

View File

@@ -0,0 +1,39 @@
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
export interface FleetCardPresentation {
titleKey: string;
subtitleKey?: string;
codeKey?: string;
statusKey?: string;
}
export const resolveFleetCardPresentation = (config: FleetResourceConfig): FleetCardPresentation => {
const titleKey =
config.cardTitleKey ??
config.columns.find((col) => col.format !== "code" && col.accessorKey !== "status")
?.accessorKey ??
"id";
const codeKey =
config.cardCodeKey ?? config.columns.find((col) => col.format === "code")?.accessorKey;
const statusKey = config.columns.find((col) => col.format === "statusBadge")?.accessorKey;
const subtitleKey =
config.cardSubtitleKey ??
config.columns.find(
(col) =>
col.accessorKey !== titleKey &&
col.accessorKey !== codeKey &&
col.accessorKey !== statusKey,
)?.accessorKey;
return { titleKey, subtitleKey, codeKey, statusKey };
};
export const cardInitials = (title: string) => {
const parts = title.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
};

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { Badge, Text } from "@mantine/core";
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
export type FleetColumnFormat = ColumnFormat | "statusBadge";
const optionLabelMap = new Map<string, Map<string, string>>();
export const registerFleetOptionLabels = (
fieldKey: string,
options: { value: string; label: string }[],
) => {
optionLabelMap.set(fieldKey, new Map(options.map((o) => [o.value, o.label])));
};
export const formatFleetCell = (
value: unknown,
format?: FleetColumnFormat,
accessorKey?: string,
): ReactNode => {
if (format === "statusBadge") {
const status = value == null || value === "" ? "—" : String(value);
return (
<Badge variant="light" color="gray" size="sm" radius="md">
{status}
</Badge>
);
}
if (accessorKey && optionLabelMap.has(accessorKey)) {
const label = optionLabelMap.get(accessorKey)?.get(String(value ?? ""));
if (label) {
return <Text size="sm">{label}</Text>;
}
}
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
};

View File

@@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from "react";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
export type FleetViewMode = "table" | "cards";
const STORAGE_PREFIX = "edr-freight-fleet-view:";
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2";
const readStored = (slug: ViewModeSlug): FleetViewMode => {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${slug}`);
return raw === "cards" ? "cards" : "table";
} catch {
return "table";
}
};
export const useFleetViewMode = (slug: ViewModeSlug) => {
const [viewMode, setViewModeState] = useState<FleetViewMode>(() => readStored(slug));
useEffect(() => {
setViewModeState(readStored(slug));
}, [slug]);
const setViewMode = useCallback(
(mode: FleetViewMode) => {
setViewModeState(mode);
try {
localStorage.setItem(`${STORAGE_PREFIX}${slug}`, mode);
} catch {
/* ignore */
}
},
[slug],
);
return { viewMode, setViewMode };
};

View File

@@ -1,4 +1,5 @@
import type { PageMeta } from "./types";
import { getFleetRouteMeta } from "@/pages/fleet/config/resources";
import {
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_RESOURCES,
@@ -35,6 +36,27 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Dashboard summary and key metrics",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2/",
meta: {
title: "Train schedule",
subtitle: "Assign bookings, auto-pin wagons, finalize and dispatch",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2",
meta: {
title: "Train Schedules v2",
subtitle: "Operational train scheduling with full allocation workflow",
},
},
{
prefix: "/dashboard/operations/train-scheduling",
meta: {
title: "Train Schedules",
subtitle: "Create and manage container train schedules",
},
},
{
prefix: "/dashboard/routes",
meta: {
@@ -42,11 +64,12 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage route definitions built from freight yards",
},
},
...getFleetRouteMeta(),
{
prefix: "/dashboard/locomotives",
prefix: "/dashboard/trains/",
meta: {
title: "Locomotives",
subtitle: "Manage locomotive master data and service status",
title: "Train detail",
subtitle: "Manage fleet consist and wagon assignments",
},
},
{
@@ -91,6 +114,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage dropdown options used across the platform",
},
},
{
prefix: "/dashboard/configuration/train-scheduling-rules",
meta: {
title: "Train scheduling rules",
subtitle: "Global limits for train length, weight, wagons, and 20ft container balance",
},
},
{
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
meta: {

View File

@@ -10,9 +10,9 @@ const links = [
icon: FileText,
},
{
title: "Train scheduling",
description: "Schedule container trains and eligible bookings",
href: "/dashboard/operations/train-scheduling",
title: "Train scheduling v2",
description: "Full allocation workflow — assign, pin wagons, finalize",
href: "/dashboard/operations/train-scheduling-v2",
icon: Train,
},
{

View File

@@ -0,0 +1,662 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import {
Button,
Card,
Checkbox,
Group,
Modal,
Paper,
Radio,
Select,
Stack,
Stepper,
Text,
} from "@mantine/core";
import { CheckCircle2 } from "lucide-react";
import {
useAvailableLocomotives,
useEligibleBookings,
useScheduleList,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useRoutes } from "@/hooks/useRoutes";
import { useToast } from "@/hooks/use-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { BookingDetail } from "@/types/booking";
import type {
ContainerPlacement,
FreightType,
ReschedulePlan,
TrainScheduleDetail,
TrainScheduleListItem,
TrainSchedulePreviewResponse,
} from "@/types/trainScheduling";
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
import {
autoFillPlacements,
mergePlacementsWithSaved,
placementsFromScheduleWagons,
validateLocalPlacements,
} from "./containerPlacement.util";
import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util";
import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary";
import { ScheduleBookingsStep } from "./ScheduleBookingsStep";
import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert";
import { SchedulingWorkflowHeader } from "./SchedulingWorkflowHeader";
import { schedulingWorkflow } from "./schedulingWorkflow.styles";
import { SchedulingStatusBadge } from "./ScheduleStatusBadge";
import { WagonPlanGrid } from "./WagonPlanGrid";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
const message = data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
const violations = data?.violations;
if (Array.isArray(violations)) return violations.join(", ");
}
return fallback;
};
const formatCountdown = (expiresAt?: string | null) => {
if (!expiresAt) return null;
const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) return "Hold expired";
const hours = Math.floor(diff / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
return `${hours}h ${mins}m remaining`;
};
export function AllocateBookingWizard({
booking,
opened,
onClose,
initialBookingIds,
}: {
booking: BookingDetail;
opened: boolean;
onClose: () => void;
initialBookingIds?: string[];
}) {
const navigate = useNavigate();
const { toast } = useToast();
const bookingFreightType = booking.freightType as FreightType;
const [activeStep, setActiveStep] = useState(0);
const [scheduleMode, setScheduleMode] = useState<"existing" | "new">("existing");
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
const [routeId, setRouteId] = useState("");
const scheduleDate = booking.scheduledDate;
const [locomotiveId, setLocomotiveId] = useState("");
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [assignedSchedule, setAssignedSchedule] = useState<TrainScheduleDetail | null>(null);
const [reschedulePlan, setReschedulePlan] = useState<ReschedulePlan | null>(null);
const [confirmPreempt, setConfirmPreempt] = useState(false);
const [allocationComplete, setAllocationComplete] = useState(false);
const originId = booking.originYard?.id;
const destinationId = booking.destinationYard?.id;
const eligibleFilters = useMemo(
() => ({
originStationId: originId,
destinationStationId: destinationId,
}),
[originId, destinationId],
);
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives();
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
const matchingSchedules = useMemo(
() =>
(schedulesQuery.data ?? []).filter(
(s: TrainScheduleListItem) =>
s.status === "DRAFT" &&
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),
),
[schedulesQuery.data, bookingFreightType],
);
const allBookingIds = useMemo(
() => [booking.id, ...extraBookingIds.filter((id) => id !== booking.id)],
[booking.id, extraBookingIds],
);
const containerUnits = previewResult?.containerUnits ?? [];
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
const hasContainerStep = useMemo(
() =>
shouldShowContainerPlacementStep({
containerUnitCount: containerUnits.length,
scheduleFreightType: booking.freightType,
bookingFreightTypes: [
booking.freightType,
...(eligibleQuery.data?.items ?? [])
.filter((item) => allBookingIds.includes(item.id))
.map((item) => item.freightType),
],
}),
[
allBookingIds,
booking.freightType,
containerUnits.length,
eligibleQuery.data?.items,
],
);
const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined;
const finalizeStep = hasContainerStep ? 3 : 2;
const stepLabels = [
"Bookings",
"Wagon plan",
...(hasContainerStep ? ["Containers"] : []),
"Finalize",
];
useEffect(() => {
if (!opened) {
setActiveStep(0);
setPreviewResult(null);
setAssignedSchedule(null);
setExtraBookingIds([]);
setContainerPlacements([]);
setReschedulePlan(null);
setConfirmPreempt(false);
setAllocationComplete(false);
return;
}
if (initialBookingIds?.length) {
setExtraBookingIds(initialBookingIds.filter((id) => id !== booking.id));
}
}, [opened, booking.id, initialBookingIds]);
useEffect(() => {
if (matchingSchedules.length && !selectedScheduleId) {
setSelectedScheduleId(matchingSchedules[0].id);
}
}, [matchingSchedules, selectedScheduleId]);
const savedPlacementsFromSchedule = useMemo(
() =>
assignedSchedule?.trainSet?.wagons
? placementsFromScheduleWagons(assignedSchedule.trainSet.wagons)
: [],
[assignedSchedule?.trainSet?.wagons],
);
useEffect(() => {
if (!containerUnits.length || !containerSlots.length) return;
setContainerPlacements((current) => {
if (current.length && current.some((p) => p.containerNumber?.trim())) {
return current;
}
const autoFilled = autoFillPlacements(containerUnits, containerSlots);
if (savedPlacementsFromSchedule.length) {
return mergePlacementsWithSaved(autoFilled, savedPlacementsFromSchedule);
}
if (current.length) return current;
return autoFilled;
});
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.isActive),
[routesQuery.data],
);
const ensureSchedule = async (): Promise<string> => {
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
if (!routeId || !scheduleDate || !locomotiveId) {
throw new Error("Select route, date, and locomotive");
}
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
});
setSelectedScheduleId(created.id);
return created.id;
};
const handlePreview = async () => {
if (!originId || !destinationId) {
toast({ title: "Booking missing origin or destination", variant: "destructive" });
return;
}
try {
const targetScheduleId =
scheduleMode === "existing" ? (selectedScheduleId ?? undefined) : undefined;
const result = await preview.mutateAsync({
payload: {
bookingIds: allBookingIds,
scheduleDate,
originStationId: originId,
destinationStationId: destinationId,
targetScheduleId,
},
});
setPreviewResult(result);
if (booking.isGovernment && targetScheduleId) {
const plan = (await trainSchedulingService.previewReschedule(targetScheduleId, {
incomingBookingIds: allBookingIds,
trigger: "GOVERNMENT_PREEMPT",
})) as ReschedulePlan;
setReschedulePlan(plan);
} else {
setReschedulePlan(null);
}
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
const autoFilled = autoFillPlacements(
result.containerUnits,
result.containerSlotSequenceNos,
);
setContainerPlacements(autoFilled);
}
setActiveStep(1);
} catch (err) {
toast({
title: "Preview failed",
description: parseError(err, "Could not preview"),
variant: "destructive",
});
}
};
const handleAssign = async () => {
if (hasContainerStep) {
const issues = validateLocalPlacements(containerUnits, containerPlacements);
if (issues.length) {
toast({
title: "Complete container assignments",
description: issues.join(", "),
variant: "destructive",
});
return;
}
}
if (reschedulePlan?.displaced.length && !confirmPreempt) {
toast({
title: "Confirm displacement",
description: "Acknowledge displaced bookings before assigning",
variant: "destructive",
});
return;
}
try {
const scheduleId = await ensureSchedule();
let result: TrainScheduleDetail;
if (reschedulePlan?.displaced.length) {
const executed = await trainSchedulingService.executeReschedule(scheduleId, {
incomingBookingIds: allBookingIds,
trigger: "GOVERNMENT_PREEMPT",
finalBookingIds: reschedulePlan.finalBookingIds,
displacedBookingIds: reschedulePlan.displaced.map((b) => b.id),
});
result = (executed as { schedule: TrainScheduleDetail }).schedule;
} else {
result = await assign.mutateAsync({
id: scheduleId,
freightType: previewFreightType,
payload: {
bookingIds: allBookingIds,
forceAssign,
containerPlacements: hasContainerStep ? containerPlacements : undefined,
},
});
}
setAssignedSchedule(result);
const saved = result.trainSet?.wagons
? placementsFromScheduleWagons(result.trainSet.wagons)
: [];
if (saved.length) {
setContainerPlacements(saved);
}
setActiveStep(finalizeStep);
toast({ title: "Bookings assigned — wagons auto-pinned" });
if (result.deferredBookings?.length) {
toast({
title: "Partial assignment",
description: `${result.deferredBookings.length} booking(s) deferred to next train`,
});
}
} catch (err) {
toast({
title: "Assign failed",
description: parseError(err, "Could not assign"),
variant: "destructive",
});
}
};
const handleFinalize = async () => {
const scheduleId = assignedSchedule?.id ?? selectedScheduleId;
if (!scheduleId) return;
try {
const finalized = await finalize.mutateAsync(scheduleId);
setAssignedSchedule(finalized);
setAllocationComplete(true);
toast({ title: "Schedule finalized — booking allocated" });
} catch (err) {
toast({
title: "Finalize failed",
description: parseError(err, "Could not finalize schedule"),
variant: "destructive",
});
}
};
const holdCountdown = formatCountdown(booking.holdExpiresAt);
const stepDescription =
activeStep === 0
? "Select & preview"
: activeStep === 1
? "Allocations"
: hasContainerStep && activeStep === 2
? "Map units"
: "Depart";
const stepIcon =
activeStep === 0
? "package"
: activeStep === 1
? "layout"
: hasContainerStep && activeStep === 2
? "container"
: "check";
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>Allocate booking {booking.reference}</Text>}
size="90%"
radius="xl"
centered
styles={{ content: { maxWidth: 1200 } }}
>
<Stack gap="lg">
<SchedulingWorkflowHeader
title="Allocation workflow"
subtitle={`${booking.reference} · ${booking.originYard?.name ?? "Origin"}${booking.destinationYard?.name ?? "Destination"}`}
activeStep={activeStep}
totalSteps={stepLabels.length}
stepLabel={stepLabels[activeStep] ?? ""}
stepDescription={stepDescription}
stepIcon={stepIcon}
/>
<Stepper
active={activeStep}
onStepClick={setActiveStep}
color={schedulingWorkflow.stepper.color}
iconSize={schedulingWorkflow.stepper.iconSize}
size={schedulingWorkflow.stepper.size}
>
<Stepper.Step label="Bookings" description="Select & preview">
<Stack gap="md" mt="lg">
<Card withBorder padding="md" radius="xl">
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600}>{booking.reference}</Text>
<SchedulingStatusBadge status={booking.schedulingStatus} />
</Group>
<Text size="sm" c="dimmed">
{booking.freightType} · {booking.cargoTotalWeightVgm}T
</Text>
<Text size="sm">
{booking.originYard?.name ?? "Origin"} {" "}
{booking.destinationYard?.name ?? "Destination"}
</Text>
{booking.freightType === "CONTAINER" && booking.bookingContainers?.length ? (
<Text size="sm" c="dimmed">
{booking.bookingContainers.map((c) => `${c.quantity}× container`).join(", ")}
</Text>
) : null}
{holdCountdown ? (
<Text size="sm" c={holdCountdown.includes("expired") ? "red" : "yellow"}>
Hold window: {holdCountdown}
</Text>
) : null}
</Stack>
</Card>
<Paper p="md" radius="xl" withBorder>
<Stack gap="md">
<Text fw={600} size="sm">
Train schedule
</Text>
<Radio.Group
value={scheduleMode}
onChange={(v) => setScheduleMode(v as "existing" | "new")}
>
<Stack gap="sm">
<Radio value="existing" label="Use existing draft schedule" />
<Radio value="new" label="Create new schedule" />
</Stack>
</Radio.Group>
{scheduleMode === "existing" ? (
<Select
label="Draft schedule"
data={matchingSchedules.map((s) => ({
value: s.id,
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
}))}
value={selectedScheduleId}
onChange={setSelectedScheduleId}
searchable
/>
) : (
<Stack gap="sm">
<Select
label="Route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<Select
label="Locomotive"
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: l.code,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
/>
</Stack>
)}
</Stack>
</Paper>
<ScheduleBookingsStep
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
selectedIds={allBookingIds}
onSelectionChange={(ids) => {
setExtraBookingIds(ids.filter((id) => id !== booking.id));
}}
freightType={bookingFreightType}
/>
<Group align="center" wrap="wrap">
<Button loading={preview.isPending} onClick={handlePreview}>
Preview plan
</Button>
<Checkbox
label="Force assign (bypass hold/overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
/>
</Group>
{previewResult ? (
<Stack gap="sm">
<ScheduleWarningsAlert
violations={previewResult.violations}
warnings={previewResult.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult.fleetAvailability}
deferredBookings={previewResult.deferredBookings}
/>
<PreviewSummary summary={previewResult.summary} />
</Stack>
) : null}
</Stack>
</Stepper.Step>
<Stepper.Step label="Wagon plan" description="Allocations">
<Stack gap="md" mt="lg">
<ScheduleWarningsAlert
violations={previewResult?.violations}
warnings={previewResult?.warnings}
/>
{reschedulePlan?.displaced.length ? (
<Card withBorder padding="md" radius="xl">
<Stack gap="sm">
<Text fw={600} size="sm" c="orange">
Government preempt bookings to displace
</Text>
{reschedulePlan.displaced.map((b) => (
<Text key={b.id} size="sm">
{b.reference} (priority {b.priorityScore})
</Text>
))}
<Checkbox
label="I confirm displacing the bookings listed above"
checked={confirmPreempt}
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
/>
</Stack>
</Card>
) : null}
<PreviewSummary summary={previewResult?.summary} />
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid
wagonPlan={previewResult?.wagonPlan ?? []}
freightType={previewFreightType ?? bookingFreightType}
/>
<Group>
{!hasContainerStep ? (
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
Assign bookings
</Button>
) : (
<Button variant="light" onClick={() => setActiveStep(2)}>
Continue to containers
</Button>
)}
<Button variant="default" onClick={handlePreview}>
Refresh preview
</Button>
</Group>
</Stack>
</Stepper.Step>
{hasContainerStep ? (
<Stepper.Step label="Containers" description="Map units">
<Stack gap="md" mt="lg">
{!containerUnits.length ? (
<Paper p="md" radius="xl" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run preview from the Bookings step to load container units for numbering.
</Text>
</Paper>
) : (
<ContainerPlacementGrid
units={containerUnits}
containerSlots={containerSlots}
placements={containerPlacements}
onChange={setContainerPlacements}
/>
)}
<Group>
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
Assign bookings
</Button>
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
Skip to finalize
</Button>
</Group>
</Stack>
</Stepper.Step>
) : null}
<Stepper.Step label="Finalize" description="Depart">
<Stack gap="md" mt="lg">
{allocationComplete ? (
<Paper p="lg" radius="xl" withBorder bg="teal.0">
<Stack gap="md" align="center">
<CheckCircle2 size={40} color="var(--mantine-color-teal-7)" />
<Text fw={700} size="lg">
Allocation complete
</Text>
<Text size="sm" c="dimmed" ta="center">
Booking {booking.reference} is scheduled on train{" "}
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}.
</Text>
<Group>
<Button
color="teal"
onClick={() => {
onClose();
if (assignedSchedule?.id) {
navigate(
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
);
}
}}
>
View schedule
</Button>
<Button variant="default" onClick={onClose}>
Close
</Button>
</Group>
</Stack>
</Paper>
) : (
<>
<Paper p="md" radius="xl" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Finalize moves the schedule to SCHEDULED and completes the booking
allocation.
</Text>
</Paper>
<Group>
<Button color="teal" loading={finalize.isPending} onClick={handleFinalize}>
Finalize schedule
</Button>
</Group>
</>
)}
</Stack>
</Stepper.Step>
</Stepper>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,202 @@
import { useMemo } from "react";
import {
Badge,
Button,
Card,
Group,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { CheckCircle2, Container } from "lucide-react";
import type { ContainerPlacement, ContainerUnitRow } from "@/types/trainScheduling";
import { autoFillPlacements, unitKey, validateLocalPlacements } from "./containerPlacement.util";
export function ContainerPlacementGrid({
units,
containerSlots,
placements,
onChange,
}: {
units: ContainerUnitRow[];
containerSlots: number[];
placements: ContainerPlacement[];
onChange: (placements: ContainerPlacement[]) => void;
}) {
const placementMap = useMemo(() => {
const map = new Map<string, ContainerPlacement>();
for (const placement of placements) {
map.set(unitKey(placement.bookingContainerId, placement.unitIndex), placement);
}
return map;
}, [placements]);
const issues = useMemo(() => validateLocalPlacements(units, placements), [units, placements]);
const completedCount = useMemo(
() =>
units.filter((unit) => {
const placement = placementMap.get(unitKey(unit.bookingContainerId, unit.unitIndex));
return placement?.sequenceNo && placement.containerNumber?.trim();
}).length,
[units, placementMap],
);
const slotOptions = containerSlots.map((seq) => ({
value: String(seq),
label: `Wagon #${seq}`,
}));
const updatePlacement = (unit: ContainerUnitRow, patch: Partial<ContainerPlacement>) => {
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
const existing = placementMap.get(key);
const next: ContainerPlacement = {
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: existing?.sequenceNo ?? containerSlots[0] ?? 1,
containerNumber: existing?.containerNumber,
sealNumber: existing?.sealNumber,
...patch,
};
onChange([
...placements.filter(
(p) => !(p.bookingContainerId === unit.bookingContainerId && p.unitIndex === unit.unitIndex),
),
next,
]);
};
if (!units.length) {
return (
<Text size="sm" c="dimmed">
No container units in this selection.
</Text>
);
}
const progress = units.length ? Math.round((completedCount / units.length) * 100) : 0;
return (
<Stack gap="md">
<Paper p="md" radius="xl" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={4}>
<Group gap="xs">
<Container size={18} />
<Text fw={600} size="sm">
Container assignment
</Text>
</Group>
<Text size="xs" c="dimmed">
Map each booking unit to a wagon slot and enter the container number. One wagon fits
either 1×40ft or 2×20ft containers.
</Text>
</Stack>
<Button
variant="light"
size="compact-sm"
onClick={() => onChange(autoFillPlacements(units, containerSlots))}
>
Auto-fill slots
</Button>
</Group>
<Stack gap={6} mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{completedCount} of {units.length} units complete
</Text>
<Text size="xs" fw={500}>
{progress}%
</Text>
</Group>
<Progress
value={progress}
size="sm"
radius="xl"
color={issues.length ? "yellow" : "teal"}
/>
</Stack>
</Paper>
{issues.length ? (
<Stack gap={6}>
{issues.map((issue) => (
<Badge key={issue} color="red" variant="light" size="sm" w="fit-content">
{issue}
</Badge>
))}
</Stack>
) : (
<Badge
color="teal"
variant="light"
size="sm"
w="fit-content"
leftSection={<CheckCircle2 size={12} />}
>
All units mapped
</Badge>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{units.map((unit) => {
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
const placement = placementMap.get(key);
const isComplete = placement?.sequenceNo && placement.containerNumber?.trim();
return (
<Card key={key} radius="xl" padding="md" withBorder>
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="sm" fw={600}>
{unit.bookingReference}
</Text>
<Text size="xs" c="dimmed">
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
</Text>
</Stack>
<Badge size="sm" variant="light" color={isComplete ? "teal" : "gray"}>
{isComplete ? "Ready" : "Pending"}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Wagon slot"
size="sm"
data={slotOptions}
value={placement?.sequenceNo ? String(placement.sequenceNo) : null}
onChange={(value) =>
updatePlacement(unit, { sequenceNo: Number(value ?? containerSlots[0]) })
}
placeholder="Select wagon"
searchable
/>
<TextInput
label="Container number"
size="sm"
placeholder="e.g. MSCU1234567"
value={placement?.containerNumber ?? ""}
onChange={(e) =>
updatePlacement(unit, {
containerNumber: e.currentTarget.value,
})
}
/>
</SimpleGrid>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -0,0 +1,245 @@
import { useMemo, useState } from "react";
import { ArrowRight, Package } from "lucide-react";
import {
Accordion,
Badge,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Text,
} from "@mantine/core";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
import { groupBookingsByThreeHourWindow } from "@/utils/groupBookingsByThreeHourWindow";
function EligibleBookingRow({
booking,
freightType,
selected,
onToggle,
}: {
booking: EligibleContainerBooking;
freightType?: FreightType;
selected: boolean;
onToggle: () => void;
}) {
const resolvedFreightType = booking.freightType ?? freightType;
const isBulk = resolvedFreightType === "BULK";
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 10,
background: selected ? "var(--mantine-color-teal-0)" : undefined,
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Package size={14} />
<Text fw={600} size="sm">
{booking.reference}
</Text>
{resolvedFreightType ? (
<Badge variant="outline" size="xs">
{resolvedFreightType}
</Badge>
) : null}
{booking.schedulingStatus ? (
<Badge variant="light" size="xs">
{booking.schedulingStatus}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">
{booking.customer}
</Text>
<Group gap={6}>
<Text size="xs">{booking.origin}</Text>
<ArrowRight size={12} />
<Text size="xs">{booking.destination}</Text>
</Group>
<Group gap="sm">
<BookingPriorityBadge score={booking.priorityScore ?? 0} />
<Text size="xs" c="dimmed">
{isBulk
? `${booking.weightTons}T`
: `${booking.quantity} × ${booking.containerType}`}
</Text>
{booking.preferredDepartureDate ? (
<Text size="xs" c="dimmed">
{new Date(booking.preferredDepartureDate).toLocaleString("en-GB", {
timeZone: "UTC",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}{" "}
UTC
</Text>
) : null}
</Group>
</Stack>
</Group>
);
}
export function EligibleBookingsPanel({
items,
isLoading,
selectedIds,
onSelectionChange,
assignedIds = [],
freightType,
}: {
items: EligibleContainerBooking[];
isLoading?: boolean;
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
assignedIds?: string[];
freightType?: FreightType;
}) {
const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]);
const availableItems = useMemo(
() => items.filter((b) => !assignedSet.has(b.id)),
[items, assignedSet],
);
const buckets = useMemo(
() => groupBookingsByThreeHourWindow(availableItems),
[availableItems],
);
const selectableIds = useMemo(() => {
return [...assignedIds, ...availableItems.map((b) => b.id)];
}, [availableItems, assignedIds]);
const toggle = (id: string) => {
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter((x) => x !== id));
} else {
onSelectionChange([...selectedIds, id]);
}
};
const toggleBucket = (bucketIds: string[], select: boolean) => {
if (select) {
const merged = new Set([...selectedIds, ...bucketIds]);
onSelectionChange([...merged]);
} else {
onSelectionChange(selectedIds.filter((id) => !bucketIds.includes(id)));
}
};
if (isLoading) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading eligible bookings
</Text>
</Group>
);
}
if (!items.length && !assignedIds.length) {
return (
<Paper p="lg" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed" ta="center">
No eligible bookings for this corridor
</Text>
</Paper>
);
}
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text size="sm" fw={500}>
Eligible bookings ({availableItems.length})
</Text>
<Group gap="sm">
<Button
variant="light"
size="compact-sm"
onClick={() => onSelectionChange(selectableIds)}
>
Select all
</Button>
<Button variant="subtle" size="compact-sm" onClick={() => onSelectionChange(assignedIds)}>
Clear
</Button>
</Group>
</Group>
{buckets.length > 0 ? (
<Accordion defaultValue={buckets[0]?.key} variant="separated" radius="lg">
{buckets.map((bucket) => {
const bucketIds = bucket.bookings.map((b) => b.id);
const selectedInBucket = bucketIds.filter((id) => selectedIds.includes(id));
const allSelected = bucketIds.length > 0 && selectedInBucket.length === bucketIds.length;
return (
<Accordion.Item key={bucket.key} value={bucket.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Stack gap={2}>
<Text fw={600} size="sm">
{bucket.label}
</Text>
<Text size="xs" c="dimmed">
{bucket.bookings.length} booking
{bucket.bookings.length === 1 ? "" : "s"} · priority sorted
</Text>
</Stack>
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Badge variant="light" color="teal">
{selectedInBucket.length} selected
</Badge>
<Button
variant="subtle"
size="compact-xs"
onClick={(e) => {
e.stopPropagation();
toggleBucket(bucketIds, !allSelected);
}}
>
{allSelected ? "Deselect bucket" : "Select bucket"}
</Button>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{bucket.bookings.map((booking) => (
<EligibleBookingRow
key={booking.id}
booking={booking}
freightType={freightType}
selected={selectedIds.includes(booking.id)}
onToggle={() => toggle(booking.id)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text size="sm" c="dimmed">
No additional eligible bookings in this corridor.
</Text>
)}
</Stack>
);
}

View File

@@ -0,0 +1,135 @@
import {
Alert,
Badge,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { AlertTriangle, Train } from "lucide-react";
import type { DeferredBookingRow, FleetAvailabilityRow } from "@/types/trainScheduling";
export function FleetAvailabilitySummary({
fleetAvailability = [],
deferredBookings = [],
}: {
fleetAvailability?: FleetAvailabilityRow[];
deferredBookings?: DeferredBookingRow[];
}) {
if (!fleetAvailability.length && !deferredBookings.length) return null;
const totalNeeded = fleetAvailability.reduce((sum, row) => sum + row.needed, 0);
const totalAvailable = fleetAvailability.reduce((sum, row) => sum + row.available, 0);
const totalShortfall = fleetAvailability.reduce((sum, row) => sum + row.shortfall, 0);
const fillRate =
totalNeeded > 0 ? Math.round((Math.min(totalAvailable, totalNeeded) / totalNeeded) * 100) : 100;
return (
<Paper p="md" radius="xl" withBorder>
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="xs">
<Train size={18} />
<Stack gap={2}>
<Text fw={600} size="sm">
Fleet wagon availability
</Text>
<Text size="xs" c="dimmed">
Plan is capped to available physical wagons by type
</Text>
</Stack>
</Group>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "teal"}>
{fillRate}% fleet coverage
</Badge>
</Group>
{totalNeeded > 0 ? (
<Stack gap={6}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{Math.min(totalAvailable, totalNeeded)} of {totalNeeded} wagon slots can be filled
</Text>
</Group>
<Progress
value={fillRate}
size="sm"
radius="xl"
color={totalShortfall > 0 ? "yellow" : "teal"}
/>
</Stack>
) : null}
{fleetAvailability.length > 0 ? (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon type</Table.Th>
<Table.Th>Needed</Table.Th>
<Table.Th>Available</Table.Th>
<Table.Th>Shortfall</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{fleetAvailability.map((row) => (
<Table.Tr key={row.wagonTypeId}>
<Table.Td>{row.wagonTypeCode}</Table.Td>
<Table.Td>{row.needed}</Table.Td>
<Table.Td>{row.available}</Table.Td>
<Table.Td>
{row.shortfall > 0 ? (
<Badge color="red" variant="light" size="sm">
{row.shortfall}
</Badge>
) : (
<Text size="sm" c="teal">
0
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : null}
{totalShortfall > 0 || deferredBookings.length > 0 ? (
<Alert color="yellow" variant="light" radius="lg" icon={<AlertTriangle size={16} />}>
<Text size="sm">
Train will depart with available wagons only.
{deferredBookings.length
? ` ${deferredBookings.length} booking(s) will wait for the next train.`
: ""}
</Text>
</Alert>
) : null}
{deferredBookings.length > 0 ? (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{deferredBookings.map((booking) => (
<Paper key={booking.id} p="sm" radius="lg" withBorder bg="gray.0">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Text size="sm" fw={600}>
{booking.reference}
</Text>
<Text size="xs" c="dimmed">
{booking.reason}
</Text>
</Stack>
<Badge variant="light" color="orange" size="sm">
Next train
</Badge>
</Group>
</Paper>
))}
</SimpleGrid>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,207 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Group,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertTriangle, Link2, Wand2 } from "lucide-react";
import { Freight } from "@edr/types";
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
import type { Wagon } from "@/services/wagon.service";
import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability";
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
export function PinWagonsForm({
schedule,
availableWagons,
isSubmitting,
onSubmit,
autoFillOnMount = true,
}: {
schedule: TrainScheduleDetail;
availableWagons: Wagon[];
isSubmitting?: boolean;
onSubmit: (assignments: PinWagonAssignment[]) => void;
autoFillOnMount?: boolean;
}) {
const slots = schedule.trainSet?.wagons ?? [];
const [assignments, setAssignments] = useState<Record<string, string>>({});
const wagonOptionsByType = useMemo(() => {
const map = new Map<string, Array<{ value: string; label: string }>>();
for (const wagon of availableWagons) {
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
if (
!wagonMatchesScheduleDirection(wagon, schedule.direction, {
allowPinned: isPinnedOnSlot,
})
) {
continue;
}
if (wagon.status !== Freight.WagonStatus.Available && !isPinnedOnSlot) {
continue;
}
const typeId = wagon.wagonTypeId;
const list = map.get(typeId) ?? [];
list.push({ value: wagon.id, label: wagon.wagonNumber });
map.set(typeId, list);
}
return map;
}, [availableWagons, schedule.direction, slots]);
const runAutoFill = useCallback(
(preserveManual = false) => {
const existing = preserveManual ? assignments : {};
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType, existing));
},
[assignments, slots, wagonOptionsByType],
);
useEffect(() => {
if (!autoFillOnMount || !slots.length) return;
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType));
}, [schedule.id, slots, wagonOptionsByType, autoFillOnMount]);
const fillStats = useMemo(
() => countFilledSlots(slots, assignments),
[slots, assignments],
);
const progress =
fillStats.total > 0 ? Math.round((fillStats.filled / fillStats.total) * 100) : 0;
const handleSubmit = () => {
const payload: PinWagonAssignment[] = Object.entries(assignments)
.filter(([, wagonId]) => Boolean(wagonId))
.map(([trainSetWagonId, physicalWagonId]) => ({ trainSetWagonId, physicalWagonId }));
onSubmit(payload);
};
if (!slots.length) {
return (
<Text size="sm" c="dimmed">
Assign bookings first to create wagon slots.
</Text>
);
}
return (
<Stack gap="md">
<Paper p="md" radius="xl" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={4}>
<Group gap="xs">
<Link2 size={18} />
<Text fw={600} size="sm">
Pin physical wagons
</Text>
</Group>
<Text size="xs" c="dimmed">
Match each train slot to a fleet wagon. Slots are auto-filled when possible.
</Text>
</Stack>
<Button
variant="light"
size="compact-sm"
leftSection={<Wand2 size={14} />}
onClick={() => runAutoFill(false)}
>
Auto-fill all slots
</Button>
</Group>
<Stack gap={6} mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{fillStats.filled} of {fillStats.total} slots filled
</Text>
<Badge variant="light" color={progress === 100 ? "teal" : "yellow"}>
{progress}%
</Badge>
</Group>
<Progress value={progress} size="sm" radius="xl" color={progress === 100 ? "teal" : "yellow"} />
</Stack>
</Paper>
{fillStats.unfilledSlotNumbers.length > 0 ? (
<Alert
color="yellow"
variant="light"
radius="lg"
icon={<AlertTriangle size={16} />}
title="Some slots could not be auto-filled"
>
<Text size="sm">
No matching fleet wagon for slot
{fillStats.unfilledSlotNumbers.length === 1 ? "" : "s"} #
{fillStats.unfilledSlotNumbers.join(", #")}. Select manually or add wagons to the fleet.
</Text>
</Alert>
) : null}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{slots.map((slot) => {
const typeId = slot.wagonType?.id ?? "";
const options =
wagonOptionsByType.get(typeId) ??
availableWagons.map((w) => ({
value: w.id,
label: w.wagonNumber,
}));
return (
<Paper key={slot.id} p="md" radius="lg" withBorder>
<Group align="flex-end" wrap="nowrap" gap="md">
<Stack gap={2} style={{ minWidth: 90 }}>
<Group gap={6}>
<ThemeIcon size="sm" radius="md" variant="light" color="teal">
<Text size="xs" fw={700}>
{slot.sequenceNo}
</Text>
</ThemeIcon>
<Text size="sm" fw={600}>
Slot #{slot.sequenceNo}
</Text>
</Group>
<Text size="xs" c="dimmed">
{slot.wagonType?.code ?? "—"} · {slot.capacityTons}T
</Text>
</Stack>
<Select
style={{ flex: 1 }}
placeholder="Select physical wagon"
data={options}
value={assignments[slot.id] ?? null}
onChange={(value) =>
setAssignments((current) => ({
...current,
[slot.id]: value ?? "",
}))
}
searchable
/>
</Group>
</Paper>
);
})}
</SimpleGrid>
<Group justify="flex-end">
<Button color="teal" loading={isSubmitting} onClick={handleSubmit}>
Pin wagons
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core";
import toast from "react-hot-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
export function RescheduleTrainDialog({
scheduleId,
currentBookingIds,
opened,
onClose,
onComplete,
}: {
scheduleId: string;
currentBookingIds: string[];
opened: boolean;
onClose: () => void;
onComplete?: () => void;
}) {
const [newDepartureDate, setNewDepartureDate] = useState("");
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!newDepartureDate) {
toast.error("Select a new departure date");
return;
}
setLoading(true);
try {
await trainSchedulingService.maintenanceReschedule(scheduleId, {
incomingBookingIds: currentBookingIds,
newDepartureDate: new Date(newDepartureDate).toISOString(),
reason,
});
toast.success("Train rescheduled for maintenance");
onComplete?.();
onClose();
} catch {
toast.error("Reschedule failed");
} finally {
setLoading(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title="Reschedule train (maintenance)" radius="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">
Updates departure and rebalances bookings on this train. Displaced bookings return to
the operations queue when capacity is insufficient.
</Text>
<TextInput
label="New departure"
type="datetime-local"
value={newDepartureDate}
onChange={(e) => setNewDepartureDate(e.target.value)}
/>
<Textarea
label="Reason"
placeholder="e.g. Locomotive maintenance"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button loading={loading} onClick={handleSubmit}>
Reschedule
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,133 @@
import { ArrowRight, Package, Train } from "lucide-react";
import {
Badge,
Button,
Group,
Paper,
Stack,
Tabs,
Text,
} from "@mantine/core";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
import { EligibleBookingsPanel } from "./EligibleBookingsPanel";
export type AssignedBookingRow = {
id: string;
reference: string;
weightTons?: number;
};
export function ScheduleBookingsStep({
assignedBookings,
eligibleItems,
eligibleLoading,
selectedIds,
onSelectionChange,
assignedIds,
freightType,
canRemove,
onRemove,
}: {
assignedBookings: AssignedBookingRow[];
eligibleItems: EligibleContainerBooking[];
eligibleLoading?: boolean;
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
assignedIds?: string[];
freightType?: FreightType;
canRemove?: boolean;
onRemove?: (bookingId: string) => void;
}) {
return (
<Paper p="md" radius="xl" withBorder>
<Tabs defaultValue={assignedBookings.length ? "on-train" : "add"} radius="lg" variant="pills">
<Tabs.List mb="md">
<Tabs.Tab
value="on-train"
leftSection={<Train size={14} />}
rightSection={
assignedBookings.length ? (
<Badge size="xs" variant="light" color="teal" circle>
{assignedBookings.length}
</Badge>
) : undefined
}
>
On this train
</Tabs.Tab>
<Tabs.Tab value="add" leftSection={<Package size={14} />}>
Add bookings
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="on-train">
{assignedBookings.length ? (
<Stack gap="sm">
{assignedBookings.map((booking) => (
<Group
key={booking.id}
justify="space-between"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 10,
background: "var(--mantine-color-teal-0)",
}}
>
<Stack gap={4}>
<Group gap="xs">
<Text fw={600} size="sm">
{booking.reference}
</Text>
{booking.weightTons != null ? (
<Badge variant="outline" size="xs">
{booking.weightTons}T
</Badge>
) : null}
</Group>
<Group gap={6}>
<Text size="xs" c="dimmed">
Assigned to this consist
</Text>
<ArrowRight size={12} />
<Text size="xs" c="teal">
Ready for wagon plan
</Text>
</Group>
</Stack>
{canRemove && onRemove ? (
<Button
variant="subtle"
color="red"
size="compact-xs"
onClick={() => onRemove(booking.id)}
>
Remove
</Button>
) : null}
</Group>
))}
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center" py="lg">
No bookings on this train yet. Use the Add bookings tab to select eligible cargo.
</Text>
)}
</Tabs.Panel>
<Tabs.Panel value="add">
<EligibleBookingsPanel
items={eligibleItems}
isLoading={eligibleLoading}
selectedIds={selectedIds}
onSelectionChange={onSelectionChange}
assignedIds={assignedIds}
freightType={freightType}
/>
</Tabs.Panel>
</Tabs>
</Paper>
);
}

View File

@@ -0,0 +1,44 @@
import { Badge } from "@mantine/core";
const STATUS_COLORS: Record<string, string> = {
DRAFT: "gray",
SCHEDULED: "blue",
DISPATCHED: "green",
ARRIVED: "teal",
CANCELLED: "red",
};
export function ScheduleStatusBadge({ status }: { status: string }) {
return (
<Badge variant="light" color={STATUS_COLORS[status] ?? "gray"} size="sm">
{status}
</Badge>
);
}
export function FreightTypeBadge({ freightType }: { freightType?: string | null }) {
if (!freightType) return <Badge variant="light" color="gray" size="sm"></Badge>;
const color =
freightType === "BULK" ? "orange" : freightType === "MIXED" ? "grape" : "cyan";
return (
<Badge variant="light" color={color} size="sm">
{freightType}
</Badge>
);
}
export function SchedulingStatusBadge({ status }: { status?: string | null }) {
if (!status) return null;
const colors: Record<string, string> = {
NOT_SCHEDULED: "gray",
HOLDING: "yellow",
ELIGIBLE: "blue",
SCHEDULED: "indigo",
DISPATCHED: "green",
};
return (
<Badge variant="light" color={colors[status] ?? "gray"} size="sm">
{status.replace(/_/g, " ")}
</Badge>
);
}

View File

@@ -0,0 +1,75 @@
import { Alert, List, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import { AlertTriangle, XCircle } from "lucide-react";
export function ScheduleWarningsAlert({
violations = [],
warnings = [],
}: {
violations?: string[];
warnings?: string[];
}) {
if (!violations.length && !warnings.length) return null;
return (
<Stack gap="sm">
{violations.length > 0 ? (
<Alert color="red" radius="xl" icon={<XCircle size={16} />} title="Violations">
<List size="sm" spacing={4}>
{violations.map((v) => (
<List.Item key={v}>{v}</List.Item>
))}
</List>
</Alert>
) : null}
{warnings.length > 0 ? (
<Alert color="yellow" radius="xl" icon={<AlertTriangle size={16} />} title="Warnings">
<List size="sm" spacing={4}>
{warnings.map((w) => (
<List.Item key={w}>{w}</List.Item>
))}
</List>
</Alert>
) : null}
</Stack>
);
}
export function PreviewSummary({
summary,
}: {
summary?: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
}) {
if (!summary) return null;
const stats = [
{ label: "Bookings", value: String(summary.totalBookings) },
{ label: "Wagons", value: String(summary.wagonsNeeded) },
{ label: "Wagon type", value: summary.wagonType },
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (
<Paper p="md" radius="xl" withBorder bg="teal.0">
<Text size="sm" fw={600} mb="sm">
Plan summary
</Text>
<SimpleGrid cols={{ base: 2, sm: 3, md: 5 }} spacing="sm">
{stats.map((stat) => (
<Stack key={stat.label} gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
{stat.label}
</Text>
<Text size="sm" fw={600}>
{stat.value}
</Text>
</Stack>
))}
</SimpleGrid>
</Paper>
);
}

View File

@@ -0,0 +1,90 @@
import { Badge, Group, Paper, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import {
CheckCircle2,
Container,
LayoutGrid,
Link2,
Package,
} from "lucide-react";
const stepIcons: Record<string, LucideIcon> = {
package: Package,
layout: LayoutGrid,
container: Container,
link: Link2,
check: CheckCircle2,
};
export function SchedulingWorkflowHeader({
title,
subtitle,
activeStep,
totalSteps,
stepLabel,
stepDescription,
stepIcon = "package",
}: {
title: string;
subtitle?: string;
activeStep: number;
totalSteps: number;
stepLabel: string;
stepDescription?: string;
stepIcon?: keyof typeof stepIcons;
}) {
const Icon = stepIcons[stepIcon] ?? Package;
const progress = totalSteps > 0 ? Math.round(((activeStep + 1) / totalSteps) * 100) : 0;
return (
<Paper
p="md"
radius="xl"
withBorder
style={{
background:
"linear-gradient(180deg, var(--mantine-color-white) 0%, var(--mantine-color-gray-0) 100%)",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start">
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
<Icon size={20} />
</ThemeIcon>
<Stack gap={4}>
<Text fw={700} size="lg">
{title}
</Text>
{subtitle ? (
<Text size="sm" c="dimmed">
{subtitle}
</Text>
) : null}
</Stack>
</Group>
<Badge size="lg" variant="light" color="teal">
Step {activeStep + 1} of {totalSteps}
</Badge>
</Group>
<Stack gap={6} mt="md">
<Group justify="space-between">
<Text size="sm" fw={600}>
{stepLabel}
{stepDescription ? (
<Text span c="dimmed" fw={400}>
{" "}
· {stepDescription}
</Text>
) : null}
</Text>
<Text size="xs" c="dimmed">
{progress}%
</Text>
</Group>
<Progress value={progress} size="sm" radius="xl" color="teal" />
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,188 @@
import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import { Box, Package } from "lucide-react";
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
type WagonSlot = WagonPlanRow | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
slotLoadType?: string;
wagonType?: { code: string } | null;
wagonTypeCode?: string;
physicalWagonNumber?: string | null;
allocations?: TrainScheduleWagonAllocation[] | Array<{
id?: string;
bookingId: string;
bookingReference?: string | null;
allocatedWeightTons: number;
loadType?: string | null;
containerItems?: Array<{ containerNumber: string | null; grossWeightTons?: number | null }>;
bulkLoad?: { weightTons: number; cargoDescription: string | null } | null;
}>;
};
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
const normalized = loadType?.toUpperCase() ?? "";
if (normalized.includes("BULK")) return "orange";
if (normalized.includes("CONTAINER")) return "cyan";
return freightType === "BULK" ? "orange" : "cyan";
}
function slotLabel(slot: WagonSlot, freightType?: string | null) {
if ("slotLoadType" in slot && slot.slotLoadType) return slot.slotLoadType;
const fromAlloc = slot.allocations?.[0]?.loadType?.toString().toUpperCase();
if (fromAlloc) return fromAlloc;
if (freightType === "MIXED") return "MIXED";
return freightType ?? "SLOT";
}
function wagonTypeLabel(slot: WagonSlot) {
if ("wagonType" in slot && slot.wagonType?.code) return slot.wagonType.code;
if ("wagonTypeCode" in slot && slot.wagonTypeCode) return slot.wagonTypeCode;
return null;
}
export function WagonPlanGrid({
wagonPlan,
freightType,
}: {
wagonPlan: WagonSlot[];
freightType?: string | null;
}) {
if (!wagonPlan?.length) {
return (
<Card radius="lg" padding="xl" withBorder bg="gray.0">
<Stack align="center" gap="sm">
<ThemeIcon size="lg" radius="xl" variant="light" color="gray">
<Package size={20} />
</ThemeIcon>
<Text size="sm" fw={500}>
No wagon plan yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={360}>
Select bookings and run <strong>Preview plan</strong> to generate wagon slots and
allocations.
</Text>
</Stack>
</Card>
);
}
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
return (
<Stack gap="md">
<Group gap="lg">
<Text size="sm" c="dimmed">
<strong>{wagonPlan.length}</strong> wagons · <strong>{usedSlots}</strong> in use
</Text>
{isBulk ? (
<Text size="sm" c="dimmed">
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
</Text>
) : null}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
{wagonPlan.map((wagon) => {
const seq = wagon.sequenceNo;
const capacity = wagon.capacityTons;
const assigned = wagon.assignedWeightTons;
const allocations = wagon.allocations ?? [];
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const label = slotLabel(wagon, freightType);
const typeCode = wagonTypeLabel(wagon);
return (
<Card key={seq} radius="lg" padding="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size="md" radius="md" variant="light" color={loadTypeColor(label, freightType)}>
<Box size={16} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={600} size="sm">
Wagon #{seq}
</Text>
{typeCode ? (
<Text size="xs" c="dimmed">
{typeCode}
{wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}
</Text>
) : null}
</Stack>
</Group>
<Badge variant="light" size="sm" color={loadTypeColor(label, freightType)}>
{label}
</Badge>
</Group>
{label === "BULK" ? (
<Stack gap={4}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Capacity
</Text>
<Text size="xs" fw={500}>
{assigned} / {capacity}T
</Text>
</Group>
<Progress
value={utilization}
size="sm"
radius="xl"
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "green"}
/>
</Stack>
) : null}
<Stack gap={6}>
{allocations.length ? (
allocations.map((alloc, index) => (
<Card key={`${alloc.bookingId}-${index}`} padding="xs" radius="md" bg="gray.0">
<Stack gap={2}>
<Group justify="space-between" gap="xs">
<Text size="xs" fw={500} lineClamp={1}>
{alloc.bookingReference ?? alloc.bookingId}
</Text>
{label === "BULK" ? (
<Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T
</Text>
) : null}
</Group>
{"containerItems" in alloc && alloc.containerItems?.length ? (
<Text size="xs" c="dimmed">
{alloc.containerItems.length} container{alloc.containerItems.length > 1 ? "s" : ""}
</Text>
) : null}
{"bulkLoad" in alloc && alloc.bulkLoad ? (
<Text size="xs" c="dimmed" lineClamp={2}>
Bulk · {alloc.bulkLoad.weightTons}T
{alloc.bulkLoad.cargoDescription
? `${alloc.bulkLoad.cargoDescription}`
: ""}
</Text>
) : null}
</Stack>
</Card>
))
) : (
<Text size="xs" c="dimmed" fs="italic">
Empty slot
</Text>
)}
</Stack>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -0,0 +1,244 @@
import { describe, it, expect } from 'vitest';
import { autoFillPlacements, unitKey, validateLocalPlacements } from './containerPlacement.util';
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({
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: i,
containerTypeId: 'ct-1',
containerTypeCode: containerType,
label: `${containerType} ${i + 1}/${quantity}`,
grossWeightTons: 25,
sizeFt,
wagonsPerUnit,
containersPerWagon,
teuSlots: sizeFt >= 40 ? 2 : 1,
});
}
return units;
}
describe('containerPlacement.util', () => {
describe('unitKey', () => {
it('creates unique keys for units', () => {
expect(unitKey('bc-1', 0)).toBe('bc-1:0');
expect(unitKey('bc-1', 1)).toBe('bc-1:1');
expect(unitKey('bc-2', 0)).toBe('bc-2:0');
});
});
describe('autoFillPlacements', () => {
it('returns empty array when no units or slots', () => {
expect(autoFillPlacements([], [1, 2, 3])).toEqual([]);
expect(autoFillPlacements(makeUnits('20GP', 20, 1), [])).toEqual([]);
});
it('places 2×20ft containers in 1 wagon slot', () => {
const units = makeUnits('20GP', 20, 2);
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(2);
// Both 20ft containers should be in slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
});
it('places 6×20ft containers in 3 wagon slots (2 per wagon)', () => {
const units = makeUnits('20GP', 20, 6);
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(6);
// Units 0,1 -> Slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
// Units 2,3 -> Slot 2
expect(placements[2]?.sequenceNo).toBe(2);
expect(placements[3]?.sequenceNo).toBe(2);
// Units 4,5 -> Slot 3
expect(placements[4]?.sequenceNo).toBe(3);
expect(placements[5]?.sequenceNo).toBe(3);
});
it('places 1×40ft container in 1 wagon slot', () => {
const units = makeUnits('40GP', 40, 1);
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(1);
expect(placements[0]?.sequenceNo).toBe(1);
});
it('places 3×40ft containers in 3 wagon slots (1 per wagon)', () => {
const units = makeUnits('40GP', 40, 3);
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(3);
// Each 40ft container gets its own slot
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(2);
expect(placements[2]?.sequenceNo).toBe(3);
});
it('handles mixed 20ft and 40ft containers correctly', () => {
const units20 = makeUnits('20GP', 20, 2);
const units40 = makeUnits('40GP', 40, 1);
const units = [...units20, ...units40];
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(3);
// First two 20ft containers share slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
// 40ft container gets slot 2
expect(placements[2]?.sequenceNo).toBe(2);
});
it('falls back to last slot when running out of slots', () => {
const units = makeUnits('20GP', 20, 6);
const slots = [1, 2]; // Only 2 slots available
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(6);
// First 4 units fit in slots 1 and 2
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
expect(placements[2]?.sequenceNo).toBe(2);
expect(placements[3]?.sequenceNo).toBe(2);
// Remaining units fall back to last available slot (slot 2)
expect(placements[4]?.sequenceNo).toBe(2);
expect(placements[5]?.sequenceNo).toBe(2);
});
it('defaults to 2 containers per wagon when sizeFt is not provided', () => {
const units: ContainerUnitRow[] = [
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 0,
containerTypeId: 'ct-1',
containerTypeCode: '20GP',
label: 'Container 1',
grossWeightTons: 25,
// sizeFt not provided, should default to 2 per wagon
},
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 1,
containerTypeId: 'ct-1',
containerTypeCode: '20GP',
label: 'Container 2',
grossWeightTons: 25,
},
];
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
});
it('uses 1 container per wagon for 40ft when sizeFt is 40', () => {
const units: ContainerUnitRow[] = [
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 0,
containerTypeId: 'ct-1',
containerTypeCode: '40GP',
label: 'Container 1',
grossWeightTons: 25,
sizeFt: 40,
},
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 1,
containerTypeId: 'ct-1',
containerTypeCode: '40GP',
label: 'Container 2',
grossWeightTons: 25,
sizeFt: 40,
},
];
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
// Each 40ft container should get its own slot
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(2);
});
});
describe('validateLocalPlacements', () => {
it('returns empty array for valid placements', () => {
const units = makeUnits('20GP', 20, 1);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
containerNumber: 'CNTR123',
},
];
expect(validateLocalPlacements(units, placements)).toEqual([]);
});
it('returns error for missing slot', () => {
const units = makeUnits('20GP', 20, 1);
const placements: ReturnType<typeof autoFillPlacements> = [];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Slot missing'))).toBe(true);
});
it('returns error for missing container number', () => {
const units = makeUnits('20GP', 20, 1);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
// No containerNumber or containerId
},
];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Enter a container number'))).toBe(true);
});
it('returns error for duplicate container numbers', () => {
const units = makeUnits('20GP', 20, 2);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
containerNumber: 'CNTR123',
},
{
bookingContainerId: 'bc-1',
unitIndex: 1,
sequenceNo: 1,
containerNumber: 'CNTR123', // Duplicate!
},
];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Duplicate container number'))).toBe(true);
});
});
});

View File

@@ -0,0 +1,128 @@
import type { ContainerPlacement, ContainerUnitRow } from "@/types/trainScheduling";
export function unitKey(bookingContainerId: string, unitIndex: number) {
return `${bookingContainerId}:${unitIndex}`;
}
type ScheduleWagonForPlacements = {
sequenceNo: number;
allocations?: Array<{
containerItems?: Array<{
bookingContainerId?: string | null;
positionOnWagon?: number | null;
containerId?: string | null;
containerNumber?: string | null;
}>;
}>;
};
export function placementsFromScheduleWagons(
wagons: ScheduleWagonForPlacements[],
): ContainerPlacement[] {
const placements: ContainerPlacement[] = [];
for (const wagon of wagons) {
for (const allocation of wagon.allocations ?? []) {
for (const containerItem of allocation.containerItems ?? []) {
if (containerItem.bookingContainerId && containerItem.positionOnWagon != null) {
placements.push({
bookingContainerId: containerItem.bookingContainerId,
unitIndex: containerItem.positionOnWagon - 1,
sequenceNo: wagon.sequenceNo,
containerNumber: containerItem.containerNumber ?? undefined,
});
}
}
}
}
return placements;
}
export function mergePlacementsWithSaved(
autoFilled: ContainerPlacement[],
saved: ContainerPlacement[],
): ContainerPlacement[] {
const savedMap = new Map(
saved.map((placement) => [unitKey(placement.bookingContainerId, placement.unitIndex), placement]),
);
return autoFilled.map((placement) => {
const existing = savedMap.get(unitKey(placement.bookingContainerId, placement.unitIndex));
if (existing?.containerNumber?.trim()) {
return {
...placement,
containerNumber: existing.containerNumber,
containerId: undefined,
sealNumber: existing.sealNumber,
};
}
return placement;
});
}
export function autoFillPlacements(
units: ContainerUnitRow[],
containerSlots: number[],
): ContainerPlacement[] {
if (!units.length || !containerSlots.length) return [];
const placements: ContainerPlacement[] = [];
let currentSlotIndex = 0;
let unitsInCurrentSlot = 0;
for (const unit of units) {
const perWagon = unit.containersPerWagon ?? (unit.sizeFt && unit.sizeFt >= 40 ? 1 : 2);
if (unitsInCurrentSlot >= perWagon) {
currentSlotIndex += 1;
unitsInCurrentSlot = 0;
}
const sequenceNo =
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
containerSlots[containerSlots.length - 1] ??
containerSlots[0];
placements.push({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo,
});
unitsInCurrentSlot += 1;
}
return placements;
}
export function validateLocalPlacements(
units: ContainerUnitRow[],
placements: ContainerPlacement[],
): string[] {
const issues: string[] = [];
const numbers = new Set<string>();
for (const unit of units) {
const placement = placements.find(
(p) =>
p.bookingContainerId === unit.bookingContainerId && p.unitIndex === unit.unitIndex,
);
if (!placement?.sequenceNo) {
issues.push(`Slot missing for ${unit.label}`);
continue;
}
if (!placement.containerNumber?.trim()) {
issues.push(`Enter a container number for ${unit.label}`);
}
if (placement.containerNumber?.trim()) {
const normalized = placement.containerNumber.trim().toUpperCase();
if (numbers.has(normalized)) {
issues.push(`Duplicate container number ${normalized}`);
}
numbers.add(normalized);
}
}
return issues;
}

View File

@@ -0,0 +1,56 @@
export interface WagonSlotForPin {
id: string;
sequenceNo: number;
physicalWagonId?: string | null;
wagonType?: { id: string } | null;
}
export function autoFillWagonAssignments(
slots: WagonSlotForPin[],
wagonOptionsByType: Map<string, Array<{ value: string; label: string }>>,
existingAssignments: Record<string, string> = {},
): Record<string, string> {
const next: Record<string, string> = {};
const assignedWagonIds = new Set<string>();
for (const slot of slots) {
const pinnedId = slot.physicalWagonId ?? existingAssignments[slot.id];
if (pinnedId) {
next[slot.id] = pinnedId;
assignedWagonIds.add(pinnedId);
}
}
for (const slot of slots) {
if (next[slot.id]) continue;
const typeId = slot.wagonType?.id ?? "";
const options = wagonOptionsByType.get(typeId) ?? [];
const availableWagon = options.find((option) => !assignedWagonIds.has(option.value));
if (availableWagon) {
next[slot.id] = availableWagon.value;
assignedWagonIds.add(availableWagon.value);
}
}
return next;
}
export function countFilledSlots(
slots: WagonSlotForPin[],
assignments: Record<string, string>,
): { filled: number; total: number; unfilledSlotNumbers: number[] } {
const unfilledSlotNumbers: number[] = [];
for (const slot of slots) {
if (!assignments[slot.id]) {
unfilledSlotNumbers.push(slot.sequenceNo);
}
}
return {
filled: slots.length - unfilledSlotNumbers.length,
total: slots.length,
unfilledSlotNumbers,
};
}

View File

@@ -0,0 +1,14 @@
import type { FreightType } from "@/types/trainScheduling";
const isContainerFreight = (freightType?: string | null) => freightType === "CONTAINER";
/** Show container number placement step when train includes container cargo. */
export function shouldShowContainerPlacementStep(params: {
containerUnitCount: number;
scheduleFreightType?: FreightType | string | null;
bookingFreightTypes: Array<FreightType | string | null | undefined>;
}): boolean {
if (params.containerUnitCount > 0) return true;
if (isContainerFreight(params.scheduleFreightType)) return true;
return params.bookingFreightTypes.some(isContainerFreight);
}

View File

@@ -0,0 +1,28 @@
import type { MantineTheme } from "@mantine/core";
export const schedulingWorkflow = {
stepper: {
color: "teal" as const,
iconSize: 32,
size: "sm" as const,
},
card: {
radius: "xl" as const,
padding: "lg" as const,
withBorder: true,
},
heroGradient: (theme: MantineTheme) =>
`linear-gradient(135deg, ${theme.colors.teal[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
workflowGradient: (theme: MantineTheme) =>
`linear-gradient(180deg, ${theme.white} 0%, ${theme.colors.gray[0]} 100%)`,
accentColor: "teal" as const,
successColor: "teal" as const,
warningColor: "yellow" as const,
};
export const schedulingStepMeta = [
{ label: "Bookings", description: "Select & preview", icon: "package" },
{ label: "Wagon plan", description: "Allocations", icon: "layout" },
{ label: "Containers", description: "Map units", icon: "container" },
{ label: "Finalize", description: "Depart", icon: "check" },
] as const;

View File

@@ -1,19 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Train } from '@/services/trainService';
export function TrainDetailCard({ train }: { train: Train }) {
return (
<Card>
<CardHeader><CardTitle>{train.trainNumber || train.code} - {train.trainName || 'Unnamed'}</CardTitle></CardHeader>
<CardContent className="grid md:grid-cols-2 gap-4">
<div><span className="font-medium">Status:</span> {train.status}</div>
<div><span className="font-medium">Capacity:</span> {train.capacityTons} tons</div>
<div><span className="font-medium">Origin:</span> {train.originStationId || '-'}</div>
<div><span className="font-medium">Destination:</span> {train.destinationStationId || '-'}</div>
<div><span className="font-medium">Departure:</span> {train.departureTime ? new Date(train.departureTime).toLocaleString() : '-'}</div>
<div><span className="font-medium">Arrival:</span> {train.arrivalTime ? new Date(train.arrivalTime).toLocaleString() : '-'}</div>
{train.remarks && <div className="col-span-2"><span className="font-medium">Remarks:</span> {train.remarks}</div>}
</CardContent>
</Card>
);
}

View File

@@ -1,59 +0,0 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useCreateTrain, useUpdateTrain } from '@/hooks/useTrains';
import { useToast } from '@/hooks/use-toast';
interface TrainFormDialogProps {
trigger?: React.ReactNode;
train?: any;
onSuccess?: () => void;
}
export function TrainFormDialog({ trigger, train, onSuccess }: TrainFormDialogProps) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ code: '', capacityTons: 0, trainNumber: '', trainName: '' });
const createTrain = useCreateTrain();
const updateTrain = useUpdateTrain();
const { toast } = useToast();
useEffect(() => {
if (train) setForm({
code: train.code,
capacityTons: train.capacityTons,
trainNumber: train.trainNumber || '',
trainName: train.trainName || '',
});
}, [train]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (train) await updateTrain.mutateAsync({ id: train.id, data: form });
else await createTrain.mutateAsync(form);
toast({ title: train ? 'Train updated' : 'Train created', description: `${form.code} saved.` });
setOpen(false);
onSuccess?.();
} catch {
toast({ title: 'Error', description: `Failed to ${train ? 'update' : 'create'} train.`, variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger || <Button>New Train</Button>}</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{train ? 'Edit Train' : 'Create Train'}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div><Label>Code*</Label><Input required value={form.code} onChange={e => setForm({...form, code: e.target.value})} /></div>
<div><Label>Capacity (tons)*</Label><Input type="number" required value={form.capacityTons} onChange={e => setForm({...form, capacityTons: parseFloat(e.target.value)})} /></div>
<div><Label>Train Number</Label><Input value={form.trainNumber} onChange={e => setForm({...form, trainNumber: e.target.value})} /></div>
<div><Label>Train Name</Label><Input value={form.trainName} onChange={e => setForm({...form, trainName: e.target.value})} /></div>
<Button type="submit" disabled={createTrain.isPending || updateTrain.isPending}>Save</Button>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,45 +0,0 @@
import { useTrains, useDeleteTrain } from '@/hooks/useTrains';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Eye, Trash2 } from 'lucide-react';
import { Link } from 'react-router-dom';
export function TrainsTable() {
const { data: trains, isLoading } = useTrains();
const deleteTrain = useDeleteTrain();
if (isLoading) return <div>Loading trains...</div>;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Number</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Capacity (tons)</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{trains?.map(train => (
<TableRow key={train.id}>
<TableCell>{train.trainNumber || train.code}</TableCell>
<TableCell>{train.trainName || '-'}</TableCell>
<TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
<TableCell>{train.capacityTons}</TableCell>
<TableCell className="flex space-x-2">
<Link to={`/trains/${train.id}`}>
<Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button>
</Link>
<Button variant="ghost" size="icon" onClick={() => deleteTrain.mutate(train.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -1,53 +1,90 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useWagons, useAssignWagonToTrain } from '@/hooks/useWagons';
import { useToast } from '@/hooks/use-toast';
import { Plus } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { useState } from "react";
import { Plus } from "lucide-react";
import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
import { Freight } from "@edr/types";
import { useToast } from "@/hooks/use-toast";
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
export function AssignWagonDialog({ trainId }: { trainId: string }) {
const [open, setOpen] = useState(false);
const [wagonId, setWagonId] = useState('');
const [sequence, setSequence] = useState<number>();
const [wagonId, setWagonId] = useState<string | null>(null);
const [sequence, setSequence] = useState<number | "">("");
const { data: wagons } = useWagons();
const assign = useAssignWagonToTrain();
const { toast } = useToast();
const available = wagons?.filter((w:any) => w.status === 'AVAILABLE' || !w.trainId);
const available = (wagons ?? []).filter(
(w) => w.status === Freight.WagonStatus.Available || !w.trainId,
);
const wagonOptions = available.map((w) => ({
value: w.id,
label: `${w.wagonNumber} (${w.readiness.replace("_", " ").toLowerCase()})`,
}));
const handleAssign = async () => {
if (!wagonId) return;
await assign.mutateAsync({ wagonId, trainId, sequenceNumber: sequence });
toast({ title: 'Assigned', description: 'Wagon attached to train.' });
setOpen(false);
try {
await assign.mutateAsync({
wagonId,
trainId,
sequenceNumber: sequence === "" ? undefined : Number(sequence),
});
toast({ title: "Wagon attached to train" });
setOpen(false);
setWagonId(null);
setSequence("");
} catch {
toast({ title: "Failed to assign wagon", variant: "destructive" });
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />Assign Wagon</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Assign Wagon to Train</DialogTitle></DialogHeader>
<div className="space-y-4">
<div>
<Label>Wagon</Label>
<Select value={wagonId} onValueChange={setWagonId}>
<SelectTrigger><SelectValue placeholder="Select wagon" /></SelectTrigger>
<SelectContent>
{available?.map((w:any) => <SelectItem key={w.id} value={w.id}>{w.wagonNumber}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Sequence (optional)</Label>
<Input type="number" value={sequence ?? ''} onChange={e => setSequence(parseInt(e.target.value) || undefined)} />
</div>
<Button onClick={handleAssign} disabled={assign.isPending}>Assign</Button>
</div>
</DialogContent>
</Dialog>
<>
<Button
color="green"
size="sm"
radius="lg"
leftSection={<Plus size={16} />}
onClick={() => setOpen(true)}
>
Assign wagon
</Button>
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Text fw={600}>Assign wagon to train</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Select
label="Wagon"
placeholder="Select wagon"
data={wagonOptions}
value={wagonId}
onChange={setWagonId}
searchable
/>
<NumberInput
label="Sequence (optional)"
value={sequence}
onChange={(value) => setSequence(value === "" ? "" : Number(value))}
min={1}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
Assign
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
}

View File

@@ -1,105 +0,0 @@
// src/components/wagons/WagonFormDialog.tsx
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { useWagonTypes } from '@/hooks/use-wagon-types';
import { useCreateWagon, useUpdateWagon } from '@/hooks/useWagons';
import { useToast } from '@/hooks/use-toast';
interface WagonFormDialogProps {
trigger?: React.ReactNode;
wagon?: any;
onSuccess?: () => void;
}
export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogProps) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
wagonNumber: '',
wagonTypeId: '',
tareWeight: 0,
maxPayloadWeight: 0,
status: 'AVAILABLE',
notes: ''
});
const createWagon = useCreateWagon();
const updateWagon = useUpdateWagon();
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const { toast } = useToast();
useEffect(() => {
if (wagon) setForm({
wagonNumber: wagon.wagonNumber,
wagonTypeId: wagon.wagonTypeId,
tareWeight: wagon.tareWeight,
maxPayloadWeight: wagon.maxPayloadWeight,
status: wagon.status,
notes: wagon.notes || ''
});
}, [wagon]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.wagonNumber || !form.wagonTypeId) {
toast({ title: 'Missing required field', description: 'Please select a wagon type.', variant: 'destructive' });
return;
}
try {
if (wagon) await updateWagon.mutateAsync({ id: wagon.id, data: form });
else await createWagon.mutateAsync(form);
toast({ title: wagon ? 'Wagon updated' : 'Wagon created', description: `${form.wagonNumber} saved.` });
setOpen(false);
onSuccess?.();
} catch {
toast({ title: 'Error', description: `Failed to ${wagon ? 'update' : 'create'} wagon.`, variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger || <Button>New Wagon</Button>}</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{wagon ? 'Edit Wagon' : 'Create Wagon'}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div><Label>Wagon Number*</Label><Input value={form.wagonNumber} onChange={e => setForm({...form, wagonNumber: e.target.value})} /></div>
<div>
<Label>Wagon Type*</Label>
<Select
value={form.wagonTypeId}
disabled={wagonTypesLoading}
onValueChange={(value) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
setForm((current) => ({
...current,
wagonTypeId: value,
maxPayloadWeight: current.maxPayloadWeight > 0
? current.maxPayloadWeight
: Number(selectedType?.capacityTons ?? current.maxPayloadWeight),
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
<SelectContent>
{wagonTypes.map((type: any) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div><Label>Tare Weight (kg)*</Label><Input type="number" value={form.tareWeight} onChange={e => setForm({...form, tareWeight: Number(e.target.value)})} /></div>
<div><Label>Max Payload (kg)*</Label><Input type="number" value={form.maxPayloadWeight} onChange={e => setForm({...form, maxPayloadWeight: Number(e.target.value)})} /></div>
<div><Label>Status</Label><Input value={form.status} onChange={e => setForm({...form, status: e.target.value})} /></div>
<div><Label>Notes</Label><Input value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} /></div>
<Button type="submit" disabled={createWagon.isPending || updateWagon.isPending}>Save</Button>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,282 +0,0 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { useWagonTypes } from '@/hooks/use-wagon-types';
interface Wagon {
id: string;
wagonNumber: string;
wagonTypeId: string;
trainId?: string;
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
capacity: number;
emptyWeight: number;
remarks?: string;
}
interface Train {
id: string;
trainNumber: string;
}
interface WagonFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
wagon?: Wagon | null;
trains: Train[];
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function WagonFormDialog({
open,
onOpenChange,
wagon,
trains = [],
onSuccess,
}: WagonFormDialogProps) {
const queryClient = useQueryClient();
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const [formData, setFormData] = useState<Partial<Wagon>>({
wagonNumber: '',
wagonTypeId: '',
status: 'AVAILABLE',
capacity: 0,
emptyWeight: 0,
remarks: '',
});
useEffect(() => {
if (wagon) {
setFormData(wagon);
} else {
setFormData({
wagonNumber: '',
wagonTypeId: '',
status: 'AVAILABLE',
capacity: 0,
emptyWeight: 0,
remarks: '',
});
}
}, [wagon, open]);
const createMutation = useMutation({
mutationFn: (data: Partial<Wagon>) =>
axios.post(`${API_BASE_URL}/api/wagons`, data),
onSuccess: () => {
toast.success('Wagon created successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['wagons'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to create wagon'
: 'Failed to create wagon';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Wagon>) =>
axios.patch(`${API_BASE_URL}/api/wagons/${wagon?.id}`, data),
onSuccess: () => {
toast.success('Wagon updated successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['wagons'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to update wagon'
: 'Failed to update wagon';
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.wagonNumber || !formData.wagonTypeId) {
toast.error('Please fill in all required fields');
return;
}
if (wagon?.id) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{wagon ? 'Edit Wagon' : 'Create New Wagon'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="wagonNumber">Wagon Number *</Label>
<Input
id="wagonNumber"
value={formData.wagonNumber || ''}
onChange={(e) =>
setFormData({ ...formData, wagonNumber: e.target.value })
}
placeholder="e.g., W001"
required
/>
</div>
<div>
<Label htmlFor="wagonTypeId">Type *</Label>
<Select
value={formData.wagonTypeId || ''}
disabled={wagonTypesLoading}
onValueChange={(value) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
setFormData({
...formData,
wagonTypeId: value,
capacity: formData.capacity && formData.capacity > 0
? formData.capacity
: Number(selectedType?.capacityTons ?? 0),
});
}}
>
<SelectTrigger id="wagonTypeId">
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
<SelectContent>
{wagonTypes.map((type: any) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="trainId">Train (Optional)</Label>
<select
id="trainId"
value={formData.trainId || ''}
onChange={(e) =>
setFormData({ ...formData, trainId: e.target.value || undefined })
}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select a train...</option>
{trains.map(train => (
<option key={train.id} value={train.id}>
{train.trainNumber}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">Status</Label>
<select
id="status"
value={formData.status || 'AVAILABLE'}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value as any })
}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="AVAILABLE">Available</option>
<option value="IN_USE">In Use</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="RETIRED">Retired</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="capacity">Capacity *</Label>
<Input
id="capacity"
type="number"
value={formData.capacity || ''}
onChange={(e) =>
setFormData({
...formData,
capacity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="emptyWeight">Empty Weight (kg)</Label>
<Input
id="emptyWeight"
type="number"
value={formData.emptyWeight || ''}
onChange={(e) =>
setFormData({
...formData,
emptyWeight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{wagon ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,64 +1,96 @@
import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/useWagons';
//import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
//import { Button } from '@/components/ui/button';
//import { Trash2, GripVertical } from 'lucide-react';
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { useMemo } from "react";
import { Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import { ActionIcon, Badge, Group, Text, Tooltip } from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast";
import { useUnassignWagon, useWagonsByTrain } from "@/hooks/useWagons";
import type { Wagon } from "@/services/wagon.service";
import { DataTable } from "@edr/ui-common";
export function WagonsTable({ trainId }: { trainId: string }) {
const { data: wagons, refetch } = useWagonsByTrain(trainId);
const { data: wagons = [], isLoading, refetch } = useWagonsByTrain(trainId);
const unassign = useUnassignWagon();
const reorder = useReorderWagons();
const { toast } = useToast();
const onDragEnd = (result: any) => {
if (!result.destination) return;
const items = Array.from(wagons || []);
const [removed] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, removed);
reorder.mutate({ trainId, wagonIds: items.map((w:any) => w.id) });
};
const columns = useMemo((): ColumnDef<Wagon>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "wagonNumber",
header: "Number",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.wagonNumber,
},
{
id: "wagonTypeId",
header: "Type",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.wagonTypeId,
},
{
id: "sequenceNumber",
header: "Sequence",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.sequenceNumber ?? "—",
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge variant="light" color="gray" size="sm">
{row.original.status}
</Badge>
),
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group justify="flex-end">
<Tooltip label="Unassign">
<ActionIcon
variant="subtle"
color="red"
loading={unassign.isPending}
onClick={async () => {
try {
await unassign.mutateAsync(row.original.id);
await refetch();
toast({ title: "Wagon unassigned" });
} catch {
toast({ title: "Failed to unassign wagon", variant: "destructive" });
}
}}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
),
},
];
}, [unassign.isPending, refetch, toast]);
if (!wagons?.length) return <div className="text-muted-foreground">No wagons assigned.</div>;
if (!isLoading && !wagons.length) {
return (
<Text size="sm" c="dimmed" py="md">
No wagons assigned to this train.
</Text>
);
}
return (
<div></div>
// <DragDropContext onDragEnd={onDragEnd}>
// <Droppable droppableId="wagons">
// {(provided) => (
// <Table {...provided.droppableProps} ref={provided.innerRef}>
// <TableHeader>
// <TableRow>
// <TableHead className="w-10"></TableHead>
// <TableHead>Number</TableHead>
// <TableHead>Type</TableHead>
// <TableHead>Sequence</TableHead>
// <TableHead>Status</TableHead>
// <TableHead>Actions</TableHead>
// </TableRow>
// </TableHeader>
// <TableBody>
// {wagons.map((wagon, idx) => (
// <Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
// {(provided) => (
// <TableRow ref={provided.innerRef} {...provided.draggableProps}>
// <TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
// <TableCell>{wagon.wagonNumber}</TableCell>
// <TableCell>{wagon.wagonTypeId}</TableCell>
// <TableCell>{wagon.sequenceNumber}</TableCell>
// <TableCell>{wagon.status}</TableCell>
// <TableCell>
// <Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
// <Trash2 className="h-4 w-4" />
// </Button>
// </TableCell>
// </TableRow>
// )}
// </Draggable>
// ))}
// {provided.placeholder}
// </TableBody>
// </Table>
// )}
// </Droppable>
// </DragDropContext>
<DataTable
columns={columns}
data={wagons}
status={isLoading ? "loading" : "success"}
emptyMessage="No wagons assigned"
containerClassName="border-0 shadow-none bg-transparent"
/>
);
}
}

View File

@@ -1,6 +1,7 @@
import type { BookingListFilter } from "@/services/bookings.service";
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import type { TrainScheduleFilters } from "@/types/trainScheduling";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const QUERY_KEYS = {
@@ -40,14 +41,19 @@ export const QUERY_KEYS = {
TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const,
eligible: (filters?: TrainScheduleFilters) =>
["train-scheduling", "eligible-bookings", filters ?? {}] as const,
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
locomotives: () => ["train-scheduling", "locomotives"] as const,
stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
},
FLEET: {
ROOT: ["fleet"] as const,
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>

View File

@@ -94,6 +94,7 @@ export const URL_CONSTANTS = {
STAFF_REQUEST_CHANGES: (id: string) =>
`/bookings/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
GOVERNMENT_EXPEDITE: (id: string) => `/bookings/${id}/government-expedite`,
APPROVE_STEP: (id: string, stepId: string) =>
`/bookings/${id}/approval-steps/${stepId}/approve`,
REJECT_STEP: (id: string, stepId: string) =>
@@ -130,8 +131,40 @@ export const URL_CONSTANTS = {
},
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
PREVIEW: "/train-scheduling/container/preview",
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
GLOBAL_RULES: "/train-scheduling/global-rules",
PREVIEW: "/train-scheduling/preview",
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
CONTAINER: {
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
PREVIEW: "/train-scheduling/container/preview",
SCHEDULES: "/train-scheduling/container/schedules",
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
ASSIGN_BOOKINGS: (id: string) =>
`/train-scheduling/container/schedules/${id}/assign-bookings`,
CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/container/schedules/${id}/cancel`,
},
BULK: {
ELIGIBLE_BOOKINGS: "/train-scheduling/bulk/eligible-bookings",
PREVIEW: "/train-scheduling/bulk/preview",
SCHEDULES: "/train-scheduling/bulk/schedules",
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/bulk/schedules/${id}`,
ASSIGN_BOOKINGS: (id: string) =>
`/train-scheduling/bulk/schedules/${id}/assign-bookings`,
CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/bulk/schedules/${id}/cancel`,
},
UNASSIGN_BOOKING: (scheduleId: string, bookingId: string) =>
`/train-scheduling/schedules/${scheduleId}/bookings/${bookingId}`,
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`,
RESCHEDULE_EXECUTE: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/execute`,
MAINTENANCE: (id: string) => `/train-scheduling/schedules/${id}/maintenance`,
SCHEDULES: "/train-scheduling/container/schedules",
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
CANCEL_SCHEDULE: (id: string) =>

View File

@@ -6,6 +6,7 @@ import {
MessageSquareWarning,
Play,
ShieldCheck,
TrainTrack,
Truck,
XCircle,
} from "lucide-react";
@@ -30,6 +31,7 @@ export type BookingActionId =
| "rejectApproval"
| "viewContract"
| "signContractStaff"
| "allocateBooking"
| "startTransit"
| "complete"
| "cancel";
@@ -53,9 +55,25 @@ export interface BookingActionDef {
export type BookingActionContext = Pick<
BookingDetail,
"status" | "paymentCurrency" | "approvalSteps" | "reference"
"status" | "paymentCurrency" | "approvalSteps" | "reference" | "schedulingStatus"
>;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
"NOT_SCHEDULED",
"HOLDING",
"ELIGIBLE",
undefined,
null,
"",
]);
export function canAllocateBooking(booking: Pick<BookingDetail, "status" | "schedulingStatus">) {
return (
booking.status === "PAID" &&
ALLOCATABLE_SCHEDULING_STATUSES.has(booking.schedulingStatus ?? undefined)
);
}
export function getNextPendingApprovalStep(
steps?: BookingApprovalStep[] | null,
): BookingApprovalStep | undefined {
@@ -270,19 +288,45 @@ export function getBookingActions(
actions = [{ ...VIEW_CONTRACT_ACTION, label: "View executed contract", primary: true }];
break;
case "PAID":
actions = [
{
id: "startTransit",
label: "Start transit",
shortLabel: "Transit",
description: "Begin rail movement",
confirmTitle: "Start transit?",
confirmDescription: "The booking will move to in transit status.",
variant: "default",
icon: Truck,
primary: true,
},
];
if (canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })) {
actions = [
{
id: "allocateBooking",
label: "Allocate booking",
shortLabel: "Allocate",
description: "Assign to train, wagons, and finalize schedule",
confirmTitle: "Allocate booking?",
confirmDescription: "Opens the train allocation wizard.",
variant: "default",
icon: TrainTrack,
primary: true,
},
{
id: "startTransit",
label: "Start transit",
shortLabel: "Transit",
description: "Begin rail movement",
confirmTitle: "Start transit?",
confirmDescription: "The booking will move to in transit status.",
variant: "default",
icon: Truck,
},
];
} else {
actions = [
{
id: "startTransit",
label: "Start transit",
shortLabel: "Transit",
description: "Begin rail movement",
confirmTitle: "Start transit?",
confirmDescription: "The booking will move to in transit status.",
variant: "default",
icon: Truck,
primary: true,
},
];
}
break;
case "IN_TRANSIT":
actions = [
@@ -316,6 +360,11 @@ export function isContractNavAction(id: BookingActionId): boolean {
return id === "viewContract" || id === "signContractStaff";
}
/** Opens allocation wizard without confirmation dialog. */
export function isAllocateAction(id: BookingActionId): boolean {
return id === "allocateBooking";
}
export function listRowHasActions(
row: {
status: BookingStatus;
@@ -329,7 +378,8 @@ export function listRowHasActions(
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: "",
approvalSteps: row.approvalSteps,
approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.schedulingStatus,
},
user,
);

View File

@@ -19,7 +19,9 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
id: booking.id,
reference: booking.reference,
approvalSteps: booking.approvalSteps,
customerLabel: labelFromRef(booking.company, booking.companyId),
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
// customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status,
scheduledDate: booking.scheduledDate,
@@ -31,6 +33,15 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
originLabel: labelFromRef(booking.originYard),
destinationLabel: labelFromRef(booking.destinationYard),
priorityScore: booking.priorityScore ?? 0,
schedulingStatus: booking.schedulingStatus,
serviceTypeLabel:
booking.serviceType?.label ??
booking.serviceType?.name ??
booking.serviceType?.code,
serviceTypeBonus: booking.serviceType?.priorityBonusPoints ?? 0,
trainScheduleId: booking.trainScheduleId ?? null,
isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null,
createdAt: booking.createdAt,
};
}

View File

@@ -0,0 +1,35 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import { fleetService } from "@/services/fleet/fleet.service";
export function useFleetList(slug: FleetResourceSlug) {
return useQuery({
queryKey: QUERY_KEYS.FLEET.list(slug),
queryFn: () => fleetService.list(slug),
});
}
export function useFleetMutations(slug: FleetResourceSlug) {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: QUERY_KEYS.FLEET.list(slug) });
const create = useMutation({
mutationFn: (data: Record<string, unknown>) => fleetService.create(slug, data),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
fleetService.update(slug, id, data),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => fleetService.remove(slug, id),
onSuccess: invalidate,
});
return { create, update, remove };
}

View File

@@ -0,0 +1,122 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type {
AssignBookingsPayload,
CreateTrainSchedulePayload,
FreightType,
PinWagonsPayload,
TrainScheduleFilters,
TrainSchedulePreviewPayload,
} from "@/types/trainScheduling";
export const useScheduleList = (freightType?: FreightType) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
queryFn: () => trainSchedulingService.listSchedules(freightType),
});
export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""),
queryFn: () => trainSchedulingService.getScheduleById(id!, freightType),
enabled: Boolean(id),
});
export const useEligibleBookings = (
filters?: TrainScheduleFilters,
enabled = true,
freightType?: FreightType,
) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(freightType, filters),
queryFn: () => trainSchedulingService.getEligibleBookings(filters, freightType),
enabled,
});
export const useAvailableLocomotives = () =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
export const useScheduleMutations = (scheduleId?: string) => {
const qc = useQueryClient();
const invalidate = () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
if (scheduleId) {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
});
}
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
};
const create = useMutation({
mutationFn: ({
freightType,
payload,
}: {
freightType?: FreightType;
payload: CreateTrainSchedulePayload;
}) => trainSchedulingService.createSchedule(payload, freightType),
onSuccess: invalidate,
});
const preview = useMutation({
mutationFn: ({
freightType,
payload,
}: {
freightType?: FreightType;
payload: TrainSchedulePreviewPayload;
}) => trainSchedulingService.preview(payload, freightType),
});
const assign = useMutation({
mutationFn: ({
id,
freightType,
payload,
}: {
id: string;
freightType?: FreightType;
payload: AssignBookingsPayload;
}) => trainSchedulingService.assignBookings(id, payload, freightType),
onSuccess: invalidate,
});
const unassign = useMutation({
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
trainSchedulingService.unassignBooking(id, bookingId),
onSuccess: invalidate,
});
const pin = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: PinWagonsPayload }) =>
trainSchedulingService.pinWagons(id, payload),
onSuccess: invalidate,
});
const finalize = useMutation({
mutationFn: (id: string) => trainSchedulingService.finalizeSchedule(id),
onSuccess: invalidate,
});
const dispatch = useMutation({
mutationFn: (id: string) => trainSchedulingService.dispatchSchedule(id),
onSuccess: invalidate,
});
const cancel = useMutation({
mutationFn: ({ id, freightType }: { id: string; freightType?: FreightType }) =>
trainSchedulingService.cancelSchedule(id, freightType ?? "CONTAINER"),
onSuccess: invalidate,
});
return { create, preview, assign, unassign, pin, finalize, dispatch, cancel, invalidate };
};

View File

@@ -1 +0,0 @@
export * from '@/components/container_management/use-cargoes';

View File

@@ -1 +0,0 @@
export * from '@/components/container_management/use-containers';

View File

@@ -9,6 +9,7 @@ import {
Inbox,
LayoutList,
Package,
Plus,
RefreshCw,
Search,
User,
@@ -18,15 +19,12 @@ import {
Container,
Stack,
Group,
Title,
Text,
Card,
TextInput,
ActionIcon,
Badge as MantineBadge,
Button as MantineButton,
ThemeIcon,
Paper,
Tabs,
} from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -38,12 +36,19 @@ import {
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
import {
useBookingDetail,
useBookingList,
useBookingListSummary,
} from "@/hooks/bookings/useBookings";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
@@ -63,11 +68,16 @@ function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
return match.statuses.join(",");
}
type OperationsSubTab = "ready" | "scheduled";
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval");
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
@@ -77,20 +87,54 @@ export default function BookingRequestsPage() {
}, []);
const tabStatuses = getStatusesForTab(activeTab);
const isOperationsTab = activeTab === "operations";
const filter: BookingListFilter = useMemo(
() => ({
const filter: BookingListFilter = useMemo(() => {
if (isOperationsTab) {
if (operationsSubTab === "ready") {
return {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
assignedToSchedule: "false",
sortBy: "isGovernment",
sortOrder: "DESC",
tab: activeTab,
};
}
return {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "SCHEDULED,DISPATCHED",
sortBy: "scheduledDate",
sortOrder: "ASC",
tab: activeTab,
};
}
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
);
};
}, [
isOperationsTab,
operationsSubTab,
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const primaryAllocateId = allocateIds[0];
const { data: allocateBooking } = useBookingDetail(
allocateOpen ? primaryAllocateId : undefined,
);
const {
data: summary,
isLoading: summaryLoading,
@@ -123,6 +167,18 @@ export default function BookingRequestsPage() {
void refetchSummary();
}, [refetch, refetchSummary]);
const handleAllocateFromQueue = useCallback(
(ids: string[]) => {
const selected = rows.filter((b) => ids.includes(b.id));
const sorted = [...selected].sort(
(a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0),
);
setAllocateIds(sorted.map((b) => b.id));
setAllocateOpen(true);
},
[rows],
);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
@@ -394,12 +450,53 @@ export default function BookingRequestsPage() {
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<Group gap="sm">
<Button
variant="filled"
leftSection={<Plus size={16} />}
onClick={() => navigate("/dashboard/booking-requests/new")}
>
Create booking
</Button>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Group>
{showEmpty ? (
{isOperationsTab ? (
<Stack gap="md">
<Tabs
value={operationsSubTab}
onChange={(value) =>
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
}
>
<Tabs.List>
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs.List>
</Tabs>
{isError ? (
<BookingTableEmpty
isError
hasSearch={false}
onRetry={handleRefresh}
/>
) : operationsSubTab === "ready" ? (
<OperationsBookingQueue
bookings={rows}
isLoading={isLoading}
onAllocate={handleAllocateFromQueue}
/>
) : (
<OperationsScheduledBookings
bookings={rows}
isLoading={isLoading}
/>
)}
</Stack>
) : showEmpty ? (
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
@@ -438,6 +535,19 @@ export default function BookingRequestsPage() {
</Stack>
</Card>
</Stack>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}
opened={allocateOpen}
onClose={() => {
setAllocateOpen(false);
setAllocateIds([]);
void refetch();
}}
initialBookingIds={allocateIds}
/>
) : null}
</Container>
</div>
);

View File

@@ -1,12 +1,237 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
Button,
Card,
Container,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
TextInput,
Title,
} from "@mantine/core";
import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
interface ReferenceData {
yard?: Array<{ id: string; name: string; code: string }>;
service?: Array<{ id: string; name: string; code: string }>;
containers?: Array<{
size: string;
types: Array<{ id: string; name: string; code: string }>;
}>;
cargo_type?: Array<{ id: string; name: string; code: string }>;
}
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
const [governmentInstitution, setGovernmentInstitution] = useState("");
const [freightType, setFreightType] = useState<"CONTAINER" | "BULK">("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
const [weight, setWeight] = useState<number>(100);
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const { data: refData, isLoading } = useQuery({
queryKey: ["bookings", "reference-data"],
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
});
const createMutation = useMutation({
mutationFn: () =>
bookingsService.create({
isGovernment,
governmentInstitution: isGovernment ? governmentInstitution : undefined,
freightType,
contractType: "NEW",
equipmentReturn: "NA",
tradeDirection: "IMPORT",
paymentCurrency: "ETB",
scheduledDate: scheduledDate || new Date().toISOString(),
originYardId,
destinationYardId,
serviceTypeId,
cargoTotalWeightVgm: weight,
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
containers:
freightType === "CONTAINER" && containerTypeId
? [{ containerTypeId, quantity: 1, vgmPerUnitTons: weight }]
: undefined,
}),
onSuccess: async (booking) => {
if (isGovernment) {
await bookingsService.governmentExpedite(booking.id);
toast.success("Government booking created and expedited to scheduling");
} else {
toast.success("Booking created as draft");
}
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
navigate(`/dashboard/booking-requests/${booking.id}`);
},
onError: () => toast.error("Failed to create booking"),
});
const yards = (refData?.yard ?? []).map((y) => ({
value: y.id,
label: y.name ?? y.code,
}));
const services = (refData?.service ?? []).map((s) => ({
value: s.id,
label: s.name ?? s.code,
}));
const containerTypes =
refData?.containers?.flatMap((g) =>
g.types.map((t) => ({ value: t.id, label: `${g.size} · ${t.code}` })),
) ?? [];
const cargoTypes = (refData?.cargo_type ?? []).map((c) => ({
value: c.id,
label: c.name ?? c.code,
}));
const canSubmit =
originYardId &&
destinationYardId &&
serviceTypeId &&
scheduledDate &&
(!isGovernment || governmentInstitution.trim().length >= 2) &&
(freightType === "BULK" ? cargoTypeId : containerTypeId);
const NewBookingPage = () => {
return (
<FeaturePlaceholder
title="Create Booking"
description="Capture and validate new freight bookings from the backoffice workflow."
/>
);
};
<Container size="md" py="xl">
<Breadcrumbs
items={[
{ label: "Operations" },
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: "Create" },
]}
/>
<Title order={2} mt="lg" mb="md">
Create booking (staff)
</Title>
export default NewBookingPage;
<Card withBorder padding="lg" radius="lg">
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
<TextInput
label="Government institution"
placeholder="e.g. Ministry of Transport"
value={governmentInstitution}
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
required
/>
) : null}
<Select
label="Freight type"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightType}
onChange={(v) => setFreightType((v as "CONTAINER" | "BULK") ?? "CONTAINER")}
/>
<Group grow>
<Select
label="Origin yard"
data={yards}
value={originYardId}
onChange={setOriginYardId}
searchable
disabled={isLoading}
/>
<Select
label="Destination yard"
data={yards}
value={destinationYardId}
onChange={setDestinationYardId}
searchable
disabled={isLoading}
/>
</Group>
<Select
label="Service type"
data={services}
value={serviceTypeId}
onChange={setServiceTypeId}
searchable
disabled={isLoading}
/>
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
<NumberInput
label="Total weight (tons)"
value={weight}
onChange={(v) => setWeight(Number(v) || 0)}
min={0}
/>
{freightType === "CONTAINER" ? (
<Select
label="Container type"
data={containerTypes}
value={containerTypeId}
onChange={setContainerTypeId}
searchable
/>
) : (
<Select
label="Cargo type"
data={cargoTypes}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
/>
)}
<Group justify="flex-end">
<Button
variant="default"
onClick={() => navigate("/dashboard/booking-requests")}
>
Cancel
</Button>
<Button
loading={createMutation.isPending}
disabled={!canSubmit}
onClick={() => createMutation.mutate()}
>
{isGovernment ? "Create & expedite" : "Create draft"}
</Button>
</Group>
{isGovernment ? (
<Text size="xs" c="dimmed">
Government bookings skip the commercial 3-hour hold and appear in the
priority lane on the Operations tab.
</Text>
) : null}
</Stack>
</Card>
</Container>
);
}

View File

@@ -1,35 +0,0 @@
import { useCargoes } from '@/hooks/useCargoes';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { LoadCargoDialog } from '@/components/cargoes/LoadCargoDialog';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function CargoesPage() {
const { data: cargoes, refetch, isLoading } = useCargoes();
if (isLoading) return <div>Loading cargoes...</div>;
return (
<Card>
<CardHeader><CardTitle>All Cargoes</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Reference</TableHead><TableHead>Description</TableHead><TableHead>Quantity</TableHead><TableHead>Weight</TableHead><TableHead>Status</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
<TableBody>
{cargoes?.map((c:any) => (
<TableRow key={c.id}>
<TableCell>{c.cargoReference}</TableCell>
<TableCell>{c.description || '-'}</TableCell>
<TableCell>{c.quantity}</TableCell>
<TableCell>{c.weight} kg</TableCell>
<TableCell><Badge variant="outline">{c.status}</Badge></TableCell>
<TableCell>
{c.status === 'PENDING' && <LoadCargoDialog cargoId={c.id} onSuccess={() => refetch()} />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -1,314 +0,0 @@
import { useState, useMemo } from 'react';
import { useCargoes } from '@/hooks/useCargoes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Trash2, Edit, Plus, Search, AlertCircle } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import CargoFormDialog from '@/components/cargoes/CargoFormDialog';
interface Cargo {
id: string;
cargoReference: string;
description: string;
quantity: number;
weight: number;
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED';
remarks?: string;
createdAt: Date;
updatedAt: Date;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoesPageEnhanced() {
const { data: cargoes = [], isLoading, refetch } = useCargoes();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCargo, setEditingCargo] = useState<Cargo | null>(null);
const deleteMutation = useMutation({
mutationFn: (cargoId: string) =>
axios.delete(`${API_BASE_URL}/api/cargoes/${cargoId}`),
onSuccess: () => {
toast.success('Cargo deleted successfully');
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargo'
: 'Failed to delete cargo';
toast.error(message);
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: string[]) =>
Promise.all(ids.map(id => axios.delete(`${API_BASE_URL}/api/cargoes/${id}`))),
onSuccess: () => {
toast.success('Cargoes deleted successfully');
setSelectedIds(new Set());
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargoes'
: 'Failed to delete cargoes';
toast.error(message);
},
});
const filteredCargoes = useMemo(() => {
let result = cargoes;
if (searchTerm) {
const lower = searchTerm.toLowerCase();
result = result.filter(
cargo =>
cargo.cargoReference?.toLowerCase().includes(lower) ||
cargo.description?.toLowerCase().includes(lower)
);
}
if (statusFilter) {
result = result.filter(cargo => cargo.status === statusFilter);
}
return result;
}, [cargoes, searchTerm, statusFilter]);
const toggleSelect = (cargoId: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(cargoId)) {
newSelected.delete(cargoId);
} else {
newSelected.add(cargoId);
}
setSelectedIds(newSelected);
};
const toggleSelectAll = () => {
if (selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredCargoes.map(c => c.id)));
}
};
const handleFormSuccess = () => {
setIsFormOpen(false);
setEditingCargo(null);
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
};
const handleEdit = (cargo: Cargo) => {
setEditingCargo(cargo);
setIsFormOpen(true);
};
const handleDelete = (cargoId: string) => {
if (window.confirm('Are you sure you want to delete this cargo?')) {
deleteMutation.mutate(cargoId);
}
};
const handleBulkDelete = () => {
if (selectedIds.size === 0) {
toast.error('Please select at least one cargo');
return;
}
if (window.confirm(`Delete ${selectedIds.size} cargo(s)?`)) {
bulkDeleteMutation.mutate(Array.from(selectedIds));
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'PENDING':
return 'bg-gray-100 text-gray-800';
case 'LOADED':
return 'bg-blue-100 text-blue-800';
case 'IN_TRANSIT':
return 'bg-purple-100 text-purple-800';
case 'DELIVERED':
return 'bg-green-100 text-green-800';
case 'CANCELLED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const statuses = ['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'CANCELLED'];
if (isLoading) {
return <div className="p-6">Loading cargoes...</div>;
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Cargoes Management</h1>
<Button onClick={() => {
setEditingCargo(null);
setIsFormOpen(true);
}}>
<Plus className="mr-2 h-4 w-4" />
New Cargo
</Button>
</div>
{/* Filters and Search */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="text-sm font-medium mb-1 block">Search</label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by reference or description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-48">
<label className="text-sm font-medium mb-1 block">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
{statuses.map(status => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
</div>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 bg-blue-50 p-3 rounded-md">
<span className="text-sm text-gray-600">{selectedIds.size} selected</span>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleteMutation.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Cargoes Table */}
<Card>
<CardHeader>
<CardTitle>All Cargoes ({filteredCargoes.length})</CardTitle>
</CardHeader>
<CardContent>
{filteredCargoes.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<AlertCircle className="mr-2 h-5 w-5" />
No cargoes found
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0}
onChange={toggleSelectAll}
className="rounded"
/>
</TableHead>
<TableHead>Cargo Reference</TableHead>
<TableHead>Description</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Weight (kg)</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCargoes.map((cargo) => (
<TableRow key={cargo.id}>
<TableCell>
<input
type="checkbox"
checked={selectedIds.has(cargo.id)}
onChange={() => toggleSelect(cargo.id)}
className="rounded"
/>
</TableCell>
<TableCell className="font-medium">{cargo.cargoReference}</TableCell>
<TableCell className="max-w-xs truncate">{cargo.description}</TableCell>
<TableCell>{cargo.quantity}</TableCell>
<TableCell>{cargo.weight}</TableCell>
<TableCell>
<Badge className={getStatusColor(cargo.status)}>
{cargo.status}
</Badge>
</TableCell>
<TableCell>
{new Date(cargo.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(cargo)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(cargo.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Form Dialog */}
<CargoFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}
cargo={editingCargo}
onSuccess={handleFormSuccess}
/>
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { useContainers } from '@/hooks/useContainers';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function ContainersPage() {
const { data: containers, isLoading } = useContainers();
if (isLoading) return <div>Loading containers...</div>;
return (
<Card>
<CardHeader><CardTitle>All Containers</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Wagon</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{containers?.map((c:any) => (
<TableRow key={c.id}>
<TableCell>{c.containerNumber}</TableCell>
<TableCell>{c.containerTypeId}</TableCell>
<TableCell>{c.wagonId || 'Unassigned'}</TableCell>
<TableCell><Badge variant="outline">{c.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -1,732 +0,0 @@
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCargoTypes } from '@/hooks/use-cargo-types';
import { useContainerTypes } from '@/hooks/use-container-types';
import { useWagonTypes } from '@/hooks/use-wagon-types';
import { useToast } from '@/hooks/use-toast';
import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes';
import {
useContainers,
useCreateContainer,
useDeleteContainer,
useUpdateContainer,
} from '@/hooks/useContainers';
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
import {
useCreateLocomotive,
useDecommissionLocomotive,
useLocomotives,
useUpdateLocomotive,
} from '@/hooks/useLocomotives';
import type { Cargo } from '@/services/cargoService';
import type { Container } from '@/services/containerService';
import type { Locomotive } from '@/services/locomotives.service';
import type { Train } from '@/services/trains.service';
import type { Wagon } from '@/services/wagon.service';
type FormValue = string | number;
type Field = {
key: string;
label: string;
type?: 'text' | 'number' | 'select';
required?: boolean;
options?: { value: string; label: string }[];
placeholder?: string;
onValueChange?: (
value: string,
current: Record<string, FormValue>,
) => Partial<Record<string, FormValue>>;
};
type Column<T> = {
key: keyof T | string;
label: string;
render?: (item: T) => ReactNode;
};
type FleetCrudPageProps<T extends { id: string }> = {
title: string;
description: string;
addLabel: string;
entityLabel?: string;
data?: T[];
isLoading: boolean;
columns: Column<T>[];
fields: Field[];
emptyValues: Record<string, FormValue>;
searchText: (item: T) => string;
create: { mutateAsync: (data: Record<string, unknown>) => Promise<unknown>; isPending: boolean };
update: { mutateAsync: (data: { id: string; data: Record<string, unknown> }) => Promise<unknown>; isPending: boolean };
remove: { mutateAsync: (id: string) => Promise<unknown>; isPending: boolean };
removeActionLabel?: string;
removeConfirmMessage?: string;
removeSuccessMessage?: string;
hideViewAction?: boolean;
};
const normalizePayload = (values: Record<string, FormValue>) =>
Object.fromEntries(
Object.entries(values)
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value])
.filter(([, value]) => value !== ''),
);
const extractBackendErrors = (error: unknown) => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? responseData as Record<string, unknown> : undefined;
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
const rawErrors = data?.errors;
const fieldErrors: Record<string, string> = {};
if (rawErrors && typeof rawErrors === 'object' && !Array.isArray(rawErrors)) {
Object.entries(rawErrors as Record<string, unknown>).forEach(([field, value]) => {
fieldErrors[field] = Array.isArray(value) ? value.join(', ') : String(value);
});
}
const message = Array.isArray(rawMessage)
? rawMessage.join(', ')
: rawMessage
? String(rawMessage)
: 'Save failed';
return { message, fieldErrors };
};
const validateForm = (fields: Field[], values: Record<string, FormValue>) => {
const errors: Record<string, string> = {};
fields.forEach((field) => {
const value = values[field.key];
const stringValue = typeof value === 'string' ? value.trim() : String(value ?? '');
if (field.required && stringValue === '') {
errors[field.key] = `${field.label} is required`;
return;
}
if (field.type === 'number' && stringValue !== '' && !Number.isFinite(Number(value))) {
errors[field.key] = `${field.label} must be a valid number`;
}
});
return errors;
};
function FleetCrudPage<T extends { id: string }>({
title,
description,
addLabel,
entityLabel,
data,
isLoading,
columns,
fields,
emptyValues,
searchText,
create,
update,
remove,
removeActionLabel = 'Delete',
removeConfirmMessage,
removeSuccessMessage,
hideViewAction = false,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<T | null>(null);
const [viewing, setViewing] = useState<T | null>(null);
const [form, setForm] = useState(emptyValues);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { toast } = useToast();
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return data ?? [];
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
}, [data, search, searchText]);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
const left = (a as Record<string, unknown>)[sortKey];
const right = (b as Record<string, unknown>)[sortKey];
const result = String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true });
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageSize = 10;
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const toggleSort = (key: string) => {
setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortDirection('asc');
};
const openCreate = () => {
setEditing(null);
setForm(emptyValues);
setFieldErrors({});
setFormOpen(true);
};
const openEdit = (item: T) => {
setEditing(item);
setForm(
Object.fromEntries(
Object.keys(emptyValues).map((key) => [key, (item as Record<string, string | number | null | undefined>)[key] ?? '']),
),
);
setFieldErrors({});
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditing(null);
setForm(emptyValues);
setFieldErrors({});
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const validationErrors = validateForm(fields, form);
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
toast({
title: 'Save failed',
description: Object.values(validationErrors)[0],
variant: 'destructive',
});
return;
}
const payload = normalizePayload(form);
setFieldErrors({});
try {
if (editing) {
await update.mutateAsync({ id: editing.id, data: payload });
toast({ title: `${title.slice(0, -1)} updated` });
} else {
await create.mutateAsync(payload);
toast({ title: `${title.slice(0, -1)} created` });
}
closeForm();
} catch (error) {
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
setFieldErrors(backendFieldErrors);
toast({ title: 'Save failed', description: message, variant: 'destructive' });
}
};
const handleDelete = async (item: T) => {
const normalizedEntityLabel = entityLabel ?? title.slice(0, -1);
if (!window.confirm(removeConfirmMessage ?? `${removeActionLabel} this ${normalizedEntityLabel.toLowerCase()}?`)) return;
try {
await remove.mutateAsync(item.id);
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
} catch {
toast({ title: `${removeActionLabel} failed`, description: 'This record may still be referenced.', variant: 'destructive' });
}
};
const isSaving = create.isPending || update.isPending;
return (
<div className="space-y-5 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
{addLabel}
</Button>
</div>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder={`Search ${title.toLowerCase()}`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={String(column.key)}>
<button
type="button"
className="inline-flex items-center gap-1 font-medium"
onClick={() => toggleSort(String(column.key))}
>
{column.label}
{sortKey === column.key ? (sortDirection === 'asc' ? 'ASC' : 'DESC') : null}
</button>
</TableHead>
))}
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paged.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={String(column.key)}>
{column.render ? column.render(item) : String((item as Record<string, unknown>)[column.key] ?? '-')}
</TableCell>
))}
<TableCell>
<div className="flex justify-end gap-1">
{!hideViewAction ? (
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" />
</Button>
) : null}
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
<Edit className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title={removeActionLabel}>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!isLoading && filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
No records found.
</TableCell>
</TableRow>
) : null}
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
Next
</Button>
</div>
</div>
<Dialog open={formOpen} onOpenChange={(open) => (!open ? closeForm() : setFormOpen(true))}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `Edit ${title.slice(0, -1)}` : addLabel}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{fields.map((field) => {
const value = form[field.key] ?? '';
const inputValue = field.type === 'number' && value !== '' && !Number.isFinite(Number(value))
? ''
: value;
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={field.key}>{field.label}</Label>
{field.type === 'select' ? (
<Select
value={String(value)}
onValueChange={(selectedValue) =>
setForm((current) => ({
...current,
[field.key]: selectedValue,
...(field.onValueChange?.(selectedValue, current) ?? {}),
}))
}
>
<SelectTrigger id={field.key}>
<SelectValue placeholder={field.placeholder ?? `Select ${field.label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={field.key}
type={field.type ?? 'text'}
value={inputValue}
onChange={(event) =>
setForm((current) => ({
...current,
[field.key]: field.type === 'number' && event.target.value !== ''
? Number(event.target.value)
: event.target.value,
}))
}
/>
)}
{fieldErrors[field.key] ? (
<p className="text-sm text-destructive">{fieldErrors[field.key]}</p>
) : null}
</div>
);
})}
<DialogFooter>
<Button type="button" variant="outline" onClick={closeForm}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title.slice(0, -1)} details</DialogTitle>
</DialogHeader>
<div className="grid gap-3 text-sm">
{viewing
? Object.entries(viewing).map(([key, value]) => (
<div key={key} className="grid grid-cols-[150px,1fr] gap-3 border-b pb-2">
<span className="font-medium">{key}</span>
<span className="break-all text-muted-foreground">{value == null ? '-' : String(value)}</span>
</div>
))
: null}
</div>
</DialogContent>
</Dialog>
</div>
);
}
const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'}</Badge>;
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
options.find((option) => option.value === value)?.label ?? value ?? '-';
export function TrainMasterDataPage() {
const query = useTrains();
return (
<FleetCrudPage<Train>
title="Trains"
description="Manage train master data independently from train scheduling."
addLabel="Add Train"
data={query.data}
isLoading={query.isLoading}
create={useCreateTrain()}
update={useUpdateTrain()}
remove={useDeleteTrain()}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'trainNumber', label: 'Number', render: (train) => train.trainNumber || '-' },
{ key: 'trainName', label: 'Name', render: (train) => train.trainName || '-' },
{ key: 'capacityTons', label: 'Capacity (tons)' },
{ key: 'status', label: 'Status', render: (train) => statusBadge(train.status) },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
{ key: 'trainNumber', label: 'Train number' },
{ key: 'trainName', label: 'Train name' },
{ key: 'locomotiveNumber', label: 'Locomotive number' },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
{ key: 'remarks', label: 'Remarks' },
]}
emptyValues={{ code: '', capacityTons: 0, trainNumber: '', trainName: '', locomotiveNumber: '', status: 'AVAILABLE', notes: '', remarks: '' }}
/>
);
}
export function WagonsCrudPage() {
const query = useWagons();
const { data: wagonTypes = [] } = useWagonTypes();
const wagonTypeOptions = wagonTypes.map((type: any) => ({
value: type.id,
label: `${type.code} - ${type.name}`,
}));
return (
<FleetCrudPage<Wagon>
title="Wagons"
description="Manage wagon master data. Booking-based train assignment is handled in train scheduling."
addLabel="Add Wagon"
data={query.data}
isLoading={query.isLoading}
create={useCreateWagon()}
update={useUpdateWagon()}
remove={useDeleteWagon()}
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
columns={[
{ key: 'wagonNumber', label: 'Number' },
{ key: 'wagonTypeId', label: 'Type', render: (wagon) => optionLabel(wagonTypeOptions, wagon.wagonTypeId) },
{ key: 'maxPayloadWeight', label: 'Max payload' },
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
]}
fields={[
{ key: 'wagonNumber', label: 'Wagon number', required: true },
{
key: 'wagonTypeId',
label: 'Wagon type',
type: 'select',
required: true,
options: wagonTypeOptions,
onValueChange: (value, current) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
if (!selectedType || Number(current.maxPayloadWeight) > 0) return {};
return { maxPayloadWeight: Number(selectedType.capacityTons) };
},
},
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
]}
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
/>
);
}
export function ContainersCrudPage() {
const query = useContainers();
const { data: containerTypes = [] } = useContainerTypes();
const { data: wagons = [] } = useWagons();
const containerTypeOptions = containerTypes.map((type: any) => ({
value: type.id,
label: type.label ?? type.name ?? type.code,
}));
const wagonOptions = wagons.map((wagon: Wagon) => ({
value: wagon.id,
label: wagon.wagonNumber,
}));
return (
<FleetCrudPage<Container>
title="Containers"
description="Manage container master data and wagon assignments."
addLabel="Add Container"
data={query.data}
isLoading={query.isLoading}
create={useCreateContainer()}
update={useUpdateContainer()}
remove={useDeleteContainer()}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
{ key: 'containerTypeId', label: 'Type', render: (container) => optionLabel(containerTypeOptions, container.containerTypeId) },
{ key: 'wagonId', label: 'Wagon', render: (container) => optionLabel(wagonOptions, container.wagonId) },
{ key: 'maxGrossWeight', label: 'Max gross' },
{ key: 'status', label: 'Status', render: (container) => statusBadge(container.status) },
]}
fields={[
{ key: 'containerNumber', label: 'Container number', required: true },
{
key: 'containerTypeId',
label: 'Container type',
type: 'select',
required: true,
options: containerTypeOptions,
},
{
key: 'wagonId',
label: 'Wagon',
type: 'select',
options: [{ value: 'none', label: 'Unassigned' }, ...wagonOptions],
onValueChange: (value) => (value === 'none' ? { wagonId: '' } : {}),
},
{ key: 'position', label: 'Position', type: 'number' },
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxGrossWeight', label: 'Max gross weight', type: 'number', required: true },
{ key: 'sealNumber', label: 'Seal number' },
{ key: 'status', label: 'Status' },
]}
emptyValues={{ containerNumber: '', containerTypeId: '', wagonId: '', position: '', tareWeight: 0, maxGrossWeight: 0, sealNumber: '', status: 'AVAILABLE' }}
/>
);
}
export function CargoesCrudPage() {
const query = useCargoes();
const { data: cargoTypes = [] } = useCargoTypes();
const { data: containers = [] } = useContainers();
const cargoTypeOptions = cargoTypes.map((type: any) => ({
value: type.id,
label: type.cargoTypeName ?? type.cargo_type_name ?? type.name ?? type.code,
}));
const containerOptions = containers.map((container: Container) => ({
value: container.id,
label: container.containerNumber,
}));
return (
<FleetCrudPage<Cargo>
title="Cargoes"
description="Manage cargo records linked to containers."
addLabel="Add Cargo"
data={query.data}
isLoading={query.isLoading}
create={useCreateCargo()}
update={useUpdateCargo()}
remove={useDeleteCargo()}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
{ key: 'cargoTypeId', label: 'Cargo type', render: (cargo) => optionLabel(cargoTypeOptions, cargo.cargoTypeId) },
{ key: 'containerId', label: 'Container', render: (cargo) => optionLabel(containerOptions, cargo.containerId) },
{ key: 'quantity', label: 'Quantity' },
{ key: 'weight', label: 'Weight' },
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
]}
fields={[
{ key: 'cargoReference', label: 'Cargo reference', required: true },
{ key: 'shipmentId', label: 'Shipment ID', required: true },
{
key: 'containerId',
label: 'Container',
type: 'select',
required: true,
options: containerOptions,
},
{
key: 'cargoTypeId',
label: 'Cargo type',
type: 'select',
options: cargoTypeOptions,
},
{ key: 'description', label: 'Description' },
{ key: 'quantity', label: 'Quantity', type: 'number', required: true },
{ key: 'weight', label: 'Weight', type: 'number', required: true },
{ key: 'volume', label: 'Volume', type: 'number' },
{ key: 'status', label: 'Status' },
]}
emptyValues={{ cargoReference: '', shipmentId: '', containerId: '', cargoTypeId: '', description: '', quantity: 0, weight: 0, volume: '', status: 'PENDING' }}
/>
);
}
export function LocomotivesCrudPage() {
const query = useLocomotives();
return (
<FleetCrudPage<Locomotive>
title="Locomotives"
entityLabel="Locomotive"
description="Manage locomotive master data used by train scheduling and fleet operations."
addLabel="Add Locomotive"
data={query.data}
isLoading={query.isLoading}
create={useCreateLocomotive()}
update={useUpdateLocomotive()}
remove={useDecommissionLocomotive()}
removeActionLabel="Decommission"
removeConfirmMessage="Decommission this locomotive?"
removeSuccessMessage="Locomotive decommissioned"
searchText={(locomotive) =>
[
locomotive.code,
locomotive.name,
locomotive.locomotiveType,
locomotive.status,
].join(' ')
}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'name', label: 'Name', render: (locomotive) => locomotive.name || '-' },
{ key: 'locomotiveType', label: 'Type' },
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'name', label: 'Name' },
{
key: 'locomotiveType',
label: 'Locomotive type',
type: 'select',
required: true,
options: [
{ value: 'DIESEL', label: 'Diesel' },
{ value: 'ELECTRIC', label: 'Electric' },
],
},
{
key: 'status',
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
],
},
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
]}
emptyValues={{
code: '',
name: '',
locomotiveType: 'DIESEL',
status: 'AVAILABLE',
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: '',
tractionForceKn: '',
maxSpeedKmh: '',
}}
/>
);
}

View File

@@ -0,0 +1,361 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useCargoTypes } from "@/hooks/use-cargo-types";
import { useContainerTypes } from "@/hooks/use-container-types";
import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast";
import { useWagons } from "@/hooks/useWagons";
import {
FLEET_SELECT_NONE,
getFleetResource,
getFleetSlugFromPath,
type FleetFormFieldDef,
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
const FleetResourcePage = () => {
const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
const config = getFleetResource(slug);
const { toast } = useToast();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug);
const { create, update, remove } = useFleetMutations(slug);
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const { data: containerTypes = [], isLoading: containerTypesLoading } = useContainerTypes();
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
const { data: containers = [], isLoading: containersLoading } = useContainers();
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
setStatusFilter("ALL");
}, [slug, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const statusFilterOptions = useMemo(() => {
if (!hasStatusColumn) return [];
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
.filter(Boolean),
);
return [
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
);
const containerTypeOpts = (
containerTypes as Array<{ id: string; label?: string; code?: string }>
).map((t) => ({ value: t.id, label: t.label ?? t.code ?? t.id }));
const cargoTypeOpts = (
cargoTypes as Array<{ id: string; cargoTypeName?: string; code?: string }>
).map((t) => ({ value: t.id, label: t.cargoTypeName ?? t.code ?? t.id }));
const wagonOpts = (wagons as Array<{ id: string; wagonNumber: string }>).map((w) => ({
value: w.id,
label: w.wagonNumber,
}));
const containerOpts = (containers as Array<{ id: string; containerNumber: string }>).map(
(c) => ({ value: c.id, label: c.containerNumber }),
);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers]);
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
registerFleetOptionLabels("containerTypeId", dynamicOptions.containerTypes);
registerFleetOptionLabels(
"cargoTypeId",
dynamicOptions.cargoTypes.filter((o) => o.value !== FLEET_SELECT_NONE),
);
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
if (!config) return [];
return config.formFields.map((field) => {
if (!field.dynamicOptions) return field;
const options = dynamicOptions[field.dynamicOptions] ?? [];
return { ...field, type: "select" as const, options };
});
}, [config, dynamicOptions]);
const selectOptionsLoading =
wagonTypesLoading || containerTypesLoading || cargoTypesLoading || wagonsLoading || containersLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
return false;
}
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
.toLowerCase()
.includes(term),
);
});
}, [allRows, search, statusFilter, config]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
const base: ColumnDef<FleetRecord>[] = config.columns.map((col) => ({
id: col.id,
header: col.header,
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
formatFleetCell(
(row.original as unknown as Record<string, unknown>)[col.accessorKey],
col.format,
col.accessorKey,
),
}));
base.push({
id: "actions",
header: "Actions",
size: 140,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<FleetRecordActions
record={row.original}
config={config}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
/>
</div>
),
});
return base;
}, [config]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
if (!config) {
return <Navigate to="/dashboard/locomotives" replace />;
}
const handleFormSubmit = async (values: Record<string, unknown>) => {
try {
if (editing && "id" in editing) {
await update.mutateAsync({ id: String(editing.id), data: values });
toast({ title: `${config.entityLabel} updated` });
} else {
await create.mutateAsync(values);
toast({ title: `${config.entityLabel} created` });
}
setFormOpen(false);
setEditing(null);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Save failed";
toast({ title: "Save failed", description: String(message), variant: "destructive" });
}
};
const handleRemove = async () => {
if (!removeTarget || !("id" in removeTarget)) return;
try {
await remove.mutateAsync(String(removeTarget.id));
toast({
title: config.removeSuccessMessage ?? `${config.entityLabel} removed`,
});
setRemoveTarget(null);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Remove failed";
toast({ title: "Remove failed", description: String(message), variant: "destructive" });
}
};
const itemLabel = config.label.toLowerCase();
return (
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch}
addLabel={config.addLabel}
onAdd={() => {
setEditing(null);
setFormOpen(true);
}}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
hasStatusColumn && statusFilterOptions.length > 1 ? (
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
data={statusFilterOptions}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
) : undefined
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={pagedRows}
status={tableStatus}
error={
isError
? {
message: "Failed to load data",
description: error instanceof Error ? error.message : "Unknown error",
}
: undefined
}
emptyMessage={`No ${itemLabel} found`}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRows.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: itemLabel } }}
/>
)}
/>
) : (
<FleetCardGrid
config={config}
rows={pagedRows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found`}
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRows.length}
onPaginationChange={setPagination}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
/>
)}
</Stack>
</Card>
<FleetFormDialog
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open);
if (!open) setEditing(null);
}}
title={editing ? `Edit ${config.entityLabel}` : config.addLabel}
fields={formFields}
initialRecord={editing}
emptyValues={config.emptyValues}
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={selectOptionsLoading}
onSubmit={handleFormSubmit}
/>
<Modal
opened={Boolean(removeTarget)}
onClose={() => setRemoveTarget(null)}
title={<Text fw={600}>{config.removeActionLabel ?? "Delete"}</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
{config.removeConfirmMessage ??
`Are you sure you want to ${config.removeAction} this ${config.entityLabel.toLowerCase()}?`}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRemoveTarget(null)}>
Cancel
</Button>
<Button color="red" loading={remove.isPending} onClick={handleRemove}>
{config.removeActionLabel ?? "Delete"}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default FleetResourcePage;

View File

@@ -1,30 +1,45 @@
import { FormEvent, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FormEvent, useMemo, useState } from "react";
import { Edit, Eye, Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCreateRoute, useDeactivateRoute, useRouteYards, useRoutes, useUpdateRoute } from '@/hooks/useRoutes';
import { useToast } from '@/hooks/use-toast';
import type { RouteRecord, YardRef } from '@/services/routes.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import {
useCreateRoute,
useDeactivateRoute,
useRouteYards,
useRoutes,
useUpdateRoute,
} from "@/hooks/useRoutes";
import { useToast } from "@/hooks/use-toast";
import type { RouteRecord, YardRef } from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type RouteFormState = {
name: string;
milestones: string[];
};
const emptyForm = (): RouteFormState => ({ name: '', milestones: ['', ''] });
const emptyForm = (): RouteFormState => ({ name: "", milestones: ["", ""] });
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : '-');
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : "—");
const routeStops = (route: RouteRecord) =>
(route.milestones ?? [])
@@ -33,22 +48,26 @@ const routeStops = (route: RouteRecord) =>
const normalizeRouteError = (error: unknown) => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
const data =
responseData && typeof responseData === "object"
? (responseData as Record<string, unknown>)
: undefined;
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage)
? rawMessage.join(', ')
? rawMessage.join(", ")
: rawMessage
? String(rawMessage)
: 'Save failed';
: "Save failed";
};
export default function RoutesPage() {
const [search, setSearch] = useState('');
const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false);
const [viewing, setViewing] = useState<RouteRecord | null>(null);
const [editing, setEditing] = useState<RouteRecord | null>(null);
const [form, setForm] = useState<RouteFormState>(emptyForm());
const { viewMode, setViewMode } = useFleetViewMode("routes");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { toast } = useToast();
const routesQuery = useRoutes();
@@ -60,7 +79,6 @@ export default function RoutesPage() {
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return routesQuery.data ?? [];
return (routesQuery.data ?? []).filter((route) => {
const searchable = [
route.name,
@@ -71,13 +89,18 @@ export default function RoutesPage() {
...routeStops(route),
]
.filter(Boolean)
.join(' ')
.join(" ")
.toLowerCase();
return searchable.includes(query);
});
}, [routesQuery.data, search]);
const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize));
const pagedRoutes = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRoutes.slice(start, start + pagination.pageSize);
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((yard) => ({
@@ -120,7 +143,7 @@ export default function RoutesPage() {
};
const addMilestone = () => {
setForm((current) => ({ ...current, milestones: [...current.milestones, ''] }));
setForm((current) => ({ ...current, milestones: [...current.milestones, ""] }));
};
const removeMilestone = (index: number) => {
@@ -132,17 +155,15 @@ export default function RoutesPage() {
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!form.name.trim()) {
toast({ title: 'Save failed', description: 'Route name is required', variant: 'destructive' });
toast({ title: "Save failed", description: "Route name is required", variant: "destructive" });
return;
}
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
toast({
title: 'Save failed',
description: 'Select at least an origin and destination yard',
variant: 'destructive',
title: "Save failed",
description: "Select at least an origin and destination yard",
variant: "destructive",
});
return;
}
@@ -153,29 +174,25 @@ export default function RoutesPage() {
milestones: form.milestones.map((yardId) => ({ yardId })),
isActive: editing?.isActive ?? true,
};
if (editing) {
await updateMutation.mutateAsync({ id: editing.id, data: payload });
toast({ title: 'Route updated' });
toast({ title: "Route updated" });
} else {
await createMutation.mutateAsync(payload);
toast({ title: 'Route created' });
toast({ title: "Route created" });
}
resetForm();
} catch (error) {
toast({ title: 'Save failed', description: normalizeRouteError(error), variant: 'destructive' });
toast({ title: "Save failed", description: normalizeRouteError(error), variant: "destructive" });
}
};
const handleDeactivate = async (route: RouteRecord) => {
if (!window.confirm('Deactivate this route?')) return;
try {
await deactivateMutation.mutateAsync(route.id);
toast({ title: 'Route deactivated' });
toast({ title: "Route deactivated" });
} catch {
toast({ title: 'Deactivate failed', description: 'Could not deactivate route', variant: 'destructive' });
toast({ title: "Deactivate failed", description: "Could not deactivate route", variant: "destructive" });
}
};
@@ -185,195 +202,288 @@ export default function RoutesPage() {
const selectedByOthers = new Set(
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
);
return yardOptions.filter(
(option) => option.value === form.milestones[index] || !selectedByOthers.has(option.value),
);
};
const tableStatus = routesQuery.isLoading
? "loading"
: routesQuery.isError
? "error"
: "success";
const columns = useMemo((): ColumnDef<RouteRecord>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{ id: "name", header: "Name", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.name },
{
id: "origin",
header: "Origin",
meta: { headerClassName, cellClassName },
cell: ({ row }) => yardLabel(row.original.originYard),
},
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => yardLabel(row.original.destinationYard),
},
{
id: "milestones",
header: "Milestones",
meta: { headerClassName, cellClassName },
cell: ({ row }) => Math.max((row.original.milestones?.length ?? 0) - 2, 0),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={row.original.isActive ? "green" : "gray"} variant="light" size="sm">
{row.original.isActive ? "Active" : "Inactive"}
</Badge>
),
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Tooltip label="View">
<ActionIcon variant="subtle" color="gray" onClick={() => setViewing(row.original)}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
<Edit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Deactivate">
<ActionIcon
variant="subtle"
color="red"
disabled={!row.original.isActive || deactivateMutation.isPending}
onClick={() => handleDeactivate(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
),
},
];
}, [deactivateMutation.isPending]);
return (
<div className="space-y-5 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Routes</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build train routes from an ordered yard list where the first stop is the origin and the last stop is the destination.
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Add Route
</Button>
</div>
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search routes…"
addLabel="Add Route"
onAdd={openCreate}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder="Search routes"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Origin</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Milestones</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRoutes.map((route) => (
<TableRow key={route.id}>
<TableCell>{route.name}</TableCell>
<TableCell>{yardLabel(route.originYard)}</TableCell>
<TableCell>{yardLabel(route.destinationYard)}</TableCell>
<TableCell>{Math.max((route.milestones?.length ?? 0) - 2, 0)}</TableCell>
<TableCell>{route.isActive ? 'Active' : 'Inactive'}</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setViewing(route)} title="View">
<Eye className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(route)} title="Edit">
<Edit className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeactivate(route)}
title="Deactivate"
disabled={!route.isActive || deactivateMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!routesQuery.isLoading && filteredRoutes.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
No routes found.
</TableCell>
</TableRow>
) : null}
{routesQuery.isLoading ? (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<Dialog open={formOpen} onOpenChange={(open) => (!open ? resetForm() : setFormOpen(true))}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Edit Route' : 'Add Route'}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="route-name">Name</Label>
<Input
id="route-name"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
{viewMode === "table" ? (
<DataTable
columns={columns}
data={pagedRoutes}
status={tableStatus}
emptyMessage="No routes found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRoutes.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "routes" } }}
/>
)}
/>
) : (
<Stack gap={0}>
{tableStatus === "loading" ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
Loading
</Text>
) : !pagedRoutes.length ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
No routes found
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{pagedRoutes.map((route) => (
<Card key={route.id} radius="lg" padding="lg" withBorder>
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{route.name}</Text>
<Badge color={route.isActive ? "green" : "gray"} variant="light" size="sm">
{route.isActive ? "Active" : "Inactive"}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{yardLabel(route.originYard)} {yardLabel(route.destinationYard)}
</Text>
<Text size="xs" c="dimmed">
{Math.max((route.milestones?.length ?? 0) - 2, 0)} intermediate milestones
</Text>
<Group gap={6} justify="flex-end">
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
View
</Button>
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
Edit
</Button>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRoutes.length}
itemLabel="routes"
onPaginationChange={setPagination}
/>
</div>
</Stack>
)}
</Stack>
</Card>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Stops</Label>
<Button type="button" variant="outline" size="sm" onClick={addMilestone}>
<Plus className="size-4" />
Add next milestone
</Button>
</div>
{form.milestones.map((yardId, index) => {
const role = index === 0 ? 'Origin' : index === form.milestones.length - 1 ? 'Destination' : 'Milestone';
const availableOptions = availableOptionsForIndex(index);
return (
<div key={`${role}-${index}`} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[120px,1fr,auto] sm:items-center">
<p className="text-sm font-medium">{role}</p>
<Select value={yardId} onValueChange={(value) => setMilestone(index, value)}>
<SelectTrigger>
<SelectValue placeholder="Select yard" />
</SelectTrigger>
<SelectContent>
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMilestone(index)}
disabled={form.milestones.length <= 2}
title="Remove stop"
>
<Trash2 className="size-4" />
</Button>
</div>
);
})}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={resetForm}>
<Modal
opened={formOpen}
onClose={resetForm}
title={<Text fw={600}>{editing ? "Edit Route" : "Add Route"}</Text>}
size="lg"
radius="lg"
centered
>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label="Name"
value={form.name}
onChange={(e) => setForm((current) => ({ ...current, name: e.currentTarget.value }))}
/>
<Group justify="space-between">
<Text size="sm" fw={500}>
Stops
</Text>
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
Add milestone
</Button>
</Group>
{form.milestones.map((yardId, index) => {
const role =
index === 0
? "Origin"
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap">
<Text w={100} size="sm" fw={500}>
{role}
</Text>
<Select
style={{ flex: 1 }}
data={availableOptionsForIndex(index)}
value={yardId || null}
onChange={(value) => value && setMilestone(index, value)}
placeholder="Select yard"
searchable
/>
<ActionIcon
variant="subtle"
color="red"
disabled={form.milestones.length <= 2}
onClick={() => removeMilestone(index)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
);
})}
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
<Button color="green" type="submit" loading={isSaving}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Group>
</Stack>
</form>
</Modal>
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Route details</DialogTitle>
</DialogHeader>
{viewing ? (
<div className="space-y-3 text-sm">
<div>
<p className="font-medium">Name</p>
<p className="text-muted-foreground">{viewing.name}</p>
</div>
<div>
<p className="font-medium">Status</p>
<p className="text-muted-foreground">{viewing.isActive ? 'Active' : 'Inactive'}</p>
</div>
<div>
<p className="font-medium">Stops</p>
<div className="mt-2 space-y-2">
{routeStops(viewing).map((stop, index, stops) => (
<div key={`${stop}-${index}`} className="rounded-md border px-3 py-2 text-muted-foreground">
{index === 0 ? 'Origin' : index === stops.length - 1 ? 'Destination' : `Milestone ${index}`}:
{' '}
{stop}
</div>
))}
</div>
</div>
<Modal
opened={Boolean(viewing)}
onClose={() => setViewing(null)}
title={<Text fw={600}>Route details</Text>}
radius="lg"
centered
>
{viewing ? (
<Stack gap="sm">
<div>
<Text size="sm" fw={500}>
Name
</Text>
<Text size="sm" c="dimmed">
{viewing.name}
</Text>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
<div>
<Text size="sm" fw={500}>
Status
</Text>
<Text size="sm" c="dimmed">
{viewing.isActive ? "Active" : "Inactive"}
</Text>
</div>
<div>
<Text size="sm" fw={500}>
Stops
</Text>
<Stack gap={6} mt={6}>
{routeStops(viewing).map((stop, index, stops) => (
<Text key={`${stop}-${index}`} size="sm" c="dimmed">
{index === 0
? "Origin"
: index === stops.length - 1
? "Destination"
: `Milestone ${index}`}
: {stop}
</Text>
))}
</Stack>
</div>
</Stack>
) : null}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,318 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
export type FleetResourceSlug =
| "locomotives"
| "trains"
| "wagons"
| "containers"
| "cargoes";
export const FLEET_SELECT_NONE = "__none__";
export type FleetDynamicOptions =
| "wagonTypes"
| "containerTypes"
| "cargoTypes"
| "wagons"
| "containers";
export interface FleetResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat | "statusBadge";
}
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
}
export interface FleetResourceConfig {
slug: FleetResourceSlug;
label: string;
subtitle: string;
basePath: string;
addLabel: string;
entityLabel: string;
searchPlaceholder: string;
supportsSearch: boolean;
columns: FleetResourceColumn[];
formFields: FleetFormFieldDef[];
emptyValues: Record<string, unknown>;
removeAction: "delete" | "decommission";
removeActionLabel?: string;
removeConfirmMessage?: string;
removeSuccessMessage?: string;
detailPath?: string;
cardTitleKey?: string;
cardCodeKey?: string;
cardSubtitleKey?: string;
searchKeys: string[];
}
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
locomotives: "/dashboard/locomotives",
trains: "/dashboard/trains",
wagons: "/dashboard/wagons",
containers: "/dashboard/containers",
cargoes: "/dashboard/cargoes",
};
const LOCOMOTIVE_TYPE_OPTIONS = [
{ label: "Diesel", value: "DIESEL" },
{ label: "Electric", value: "ELECTRIC" },
];
const LOCOMOTIVE_STATUS_OPTIONS = [
{ label: "Available", value: "AVAILABLE" },
{ label: "Maintenance", value: "MAINTENANCE" },
{ label: "Assigned", value: "ASSIGNED" },
{ label: "Out of service", value: "OUT_OF_SERVICE" },
];
const WAGON_STATUS_OPTIONS = [
{ label: "Available", value: Freight.WagonStatus.Available },
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
{ label: "Retired", value: Freight.WagonStatus.Retired },
];
const WAGON_READINESS_OPTIONS = [
{ label: "Import ready", value: Freight.WagonReadiness.ImportReady },
{ label: "Export ready", value: Freight.WagonReadiness.ExportReady },
];
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
label: "Locomotives",
subtitle: "Manage locomotive master data used by train scheduling and fleet operations",
basePath: "/dashboard/locomotives",
addLabel: "Add Locomotive",
entityLabel: "Locomotive",
searchPlaceholder: "Search locomotives…",
supportsSearch: true,
removeAction: "decommission",
removeActionLabel: "Decommission",
removeConfirmMessage: "Decommission this locomotive?",
removeSuccessMessage: "Locomotive decommissioned",
cardTitleKey: "name",
cardCodeKey: "code",
cardSubtitleKey: "locomotiveType",
searchKeys: ["code", "name", "locomotiveType", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
code: "",
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
},
},
{
slug: "trains",
label: "Trains",
subtitle: "Manage train master data independently from train scheduling",
basePath: "/dashboard/trains",
addLabel: "Add Train",
entityLabel: "Train",
searchPlaceholder: "Search trains…",
supportsSearch: true,
removeAction: "delete",
detailPath: "/dashboard/trains/:id",
cardTitleKey: "trainName",
cardCodeKey: "code",
cardSubtitleKey: "trainNumber",
searchKeys: ["code", "trainNumber", "trainName", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "trainNumber", header: "Number", accessorKey: "trainNumber" },
{ id: "trainName", header: "Name", accessorKey: "trainName" },
{ id: "capacityTons", header: "Capacity (tons)", accessorKey: "capacityTons", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "trainNumber", label: "Train number", type: "text" },
{ name: "trainName", label: "Train name", type: "text" },
{ name: "locomotiveNumber", label: "Locomotive number", type: "text" },
{ name: "status", label: "Status", type: "text" },
{ name: "notes", label: "Notes", type: "textarea" },
{ name: "remarks", label: "Remarks", type: "textarea" },
],
emptyValues: {
code: "",
capacityTons: 0,
trainNumber: "",
trainName: "",
locomotiveNumber: "",
status: "AVAILABLE",
notes: "",
remarks: "",
},
},
{
slug: "wagons",
label: "Wagons",
subtitle: "Manage wagon master data. Operational scheduling uses train schedules separately",
basePath: "/dashboard/wagons",
addLabel: "Add Wagon",
entityLabel: "Wagon",
searchPlaceholder: "Search wagons…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "wagonNumber",
cardSubtitleKey: "readiness",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
columns: [
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
wagonNumber: "",
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
readiness: Freight.WagonReadiness.ImportReady,
status: Freight.WagonStatus.Available,
notes: "",
},
},
{
slug: "containers",
label: "Containers",
subtitle: "Manage container master data and wagon assignments",
basePath: "/dashboard/containers",
addLabel: "Add Container",
entityLabel: "Container",
searchPlaceholder: "Search containers…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "containerNumber",
cardSubtitleKey: "status",
searchKeys: ["containerNumber", "containerTypeId", "wagonId", "status"],
columns: [
{ id: "containerNumber", header: "Number", accessorKey: "containerNumber", format: "code" },
{ id: "containerTypeId", header: "Type", accessorKey: "containerTypeId", format: "entityLabel" },
{ id: "wagonId", header: "Wagon", accessorKey: "wagonId", format: "entityLabel" },
{ id: "maxGrossWeight", header: "Max gross", accessorKey: "maxGrossWeight", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "containerNumber", label: "Container number", type: "text", required: true },
{ name: "containerTypeId", label: "Container type", type: "select", required: true, dynamicOptions: "containerTypes" },
{ name: "wagonId", label: "Wagon", type: "select", dynamicOptions: "wagons", noneOption: true },
{ name: "position", label: "Position", type: "number" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxGrossWeight", label: "Max gross weight", type: "number", required: true },
{ name: "sealNumber", label: "Seal number", type: "text" },
{ name: "status", label: "Status", type: "text" },
],
emptyValues: {
containerNumber: "",
containerTypeId: "",
wagonId: "",
position: "",
tareWeight: 0,
maxGrossWeight: 0,
sealNumber: "",
status: "AVAILABLE",
},
},
{
slug: "cargoes",
label: "Cargoes",
subtitle: "Manage cargo records linked to containers",
basePath: "/dashboard/cargoes",
addLabel: "Add Cargo",
entityLabel: "Cargo",
searchPlaceholder: "Search cargoes…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "cargoReference",
cardSubtitleKey: "status",
searchKeys: ["cargoReference", "description", "containerId", "status"],
columns: [
{ id: "cargoReference", header: "Reference", accessorKey: "cargoReference", format: "code" },
{ id: "cargoTypeId", header: "Cargo type", accessorKey: "cargoTypeId", format: "entityLabel" },
{ id: "containerId", header: "Container", accessorKey: "containerId", format: "entityLabel" },
{ id: "quantity", header: "Quantity", accessorKey: "quantity", format: "number" },
{ id: "weight", header: "Weight", accessorKey: "weight", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "cargoReference", label: "Cargo reference", type: "text", required: true },
{ name: "shipmentId", label: "Shipment ID", type: "text", required: true },
{ name: "containerId", label: "Container", type: "select", required: true, dynamicOptions: "containers" },
{ name: "cargoTypeId", label: "Cargo type", type: "select", dynamicOptions: "cargoTypes", noneOption: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "quantity", label: "Quantity", type: "number", required: true },
{ name: "weight", label: "Weight", type: "number", required: true },
{ name: "volume", label: "Volume", type: "number" },
{ name: "status", label: "Status", type: "text" },
],
emptyValues: {
cargoReference: "",
shipmentId: "",
containerId: "",
cargoTypeId: "",
description: "",
quantity: 0,
weight: 0,
volume: "",
status: "PENDING",
},
},
];
export const getFleetResource = (slug: string): FleetResourceConfig | undefined =>
FLEET_RESOURCES.find((resource) => resource.slug === slug);
export const getFleetSlugFromPath = (pathname: string): FleetResourceSlug | undefined => {
const normalized = pathname.toLowerCase();
return FLEET_RESOURCES.find((resource) => normalized === resource.basePath.toLowerCase())?.slug;
};
export const getFleetRouteMeta = () =>
FLEET_RESOURCES.map((resource) => ({
prefix: resource.basePath,
meta: { title: resource.label, subtitle: resource.subtitle },
}));

View File

@@ -0,0 +1,634 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import { ArrowLeft, Train } from "lucide-react";
import {
Badge,
Button,
Card,
Checkbox,
Divider,
Group,
Loader,
Paper,
Stack,
Stepper,
Text,
Title,
} from "@mantine/core";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import {
autoFillPlacements,
mergePlacementsWithSaved,
placementsFromScheduleWagons,
validateLocalPlacements,
} from "@/components/trainScheduling/containerPlacement.util";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import {
FreightTypeBadge,
ScheduleStatusBadge,
} from "@/components/trainScheduling/ScheduleStatusBadge";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { SchedulingWorkflowHeader } from "@/components/trainScheduling/SchedulingWorkflowHeader";
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import {
useEligibleBookings,
useScheduleDetail,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type {
ContainerPlacement,
FreightType,
TrainSchedulePreviewResponse,
} from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
const message = data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
const violations = data?.violations;
if (Array.isArray(violations)) return violations.join(", ");
}
return fallback;
};
export default function TrainScheduleV2DetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [activeStep, setActiveStep] = useState(0);
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const autoPreviewedRef = useRef(false);
const detailQuery = useScheduleDetail(scheduleId);
const schedule = detailQuery.data;
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
const eligibleFilters = useMemo(
() =>
schedule
? {
originStationId: schedule.originStation?.id,
destinationStationId: schedule.destinationStation?.id,
}
: undefined,
[schedule],
);
const eligibleFreightType =
freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined;
const eligibleQuery = useEligibleBookings(
eligibleFilters,
Boolean(schedule),
eligibleFreightType,
);
const { preview, assign, unassign, finalize, dispatch } = useScheduleMutations(scheduleId);
const assignedIds = useMemo(
() => (schedule?.bookings ?? []).map((b) => b.id),
[schedule?.bookings],
);
const allSelectedIds = useMemo(() => {
const merged = new Set([...assignedIds, ...selectedBookingIds]);
return [...merged];
}, [assignedIds, selectedBookingIds]);
const containerUnits = previewResult?.containerUnits ?? [];
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
const hasContainerStep = useMemo(
() =>
shouldShowContainerPlacementStep({
containerUnitCount: containerUnits.length,
scheduleFreightType: freightType,
bookingFreightTypes: [
...(schedule?.bookings ?? []).map((b) => b.freightType),
...(eligibleQuery.data?.items ?? [])
.filter((item) => allSelectedIds.includes(item.id))
.map((item) => item.freightType),
],
}),
[
allSelectedIds,
containerUnits.length,
eligibleQuery.data?.items,
freightType,
schedule?.bookings,
],
);
const displayWagonPlan = useMemo(() => {
if (previewResult?.wagonPlan?.length) return previewResult.wagonPlan;
if (schedule?.trainSet?.wagons?.length) return schedule.trainSet.wagons;
return [];
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
const runPreview = useCallback(
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
if (!schedule || !scheduleId) return null;
if (!allSelectedIds.length) {
if (!options?.silent) {
toast({ title: "Select at least one booking", variant: "destructive" });
}
return null;
}
const originStationId = schedule.originStation?.id;
const destinationStationId = schedule.destinationStation?.id;
if (!originStationId || !destinationStationId) {
if (!options?.silent) {
toast({ title: "Schedule missing origin or destination", variant: "destructive" });
}
return null;
}
try {
const result = await preview.mutateAsync({
freightType,
payload: {
bookingIds: allSelectedIds,
scheduleDate: schedule.scheduledDepartureDate,
originStationId,
destinationStationId,
targetScheduleId: scheduleId,
},
});
setPreviewResult(result);
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
const autoFilled = autoFillPlacements(
result.containerUnits,
result.containerSlotSequenceNos,
);
const saved = schedule.trainSet?.wagons
? placementsFromScheduleWagons(schedule.trainSet.wagons)
: [];
setContainerPlacements(
saved.length ? mergePlacementsWithSaved(autoFilled, saved) : autoFilled,
);
} else {
setContainerPlacements([]);
}
if (!options?.silent) {
if (!result.valid) {
toast({ title: "Preview has violations", variant: "destructive" });
} else if (options?.advanceStep !== false) {
setActiveStep(1);
}
}
return result;
} catch (err) {
if (!options?.silent) {
toast({
title: "Preview failed",
description: parseError(err, "Could not preview"),
variant: "destructive",
});
}
return null;
}
},
[allSelectedIds, freightType, preview, schedule, scheduleId, toast],
);
useEffect(() => {
if (!schedule || !scheduleId || autoPreviewedRef.current) return;
if (!assignedIds.length) return;
autoPreviewedRef.current = true;
void runPreview({ silent: true, advanceStep: false });
}, [assignedIds.length, runPreview, schedule, scheduleId]);
const savedPlacementsFromSchedule = useMemo(
() =>
schedule?.trainSet?.wagons
? placementsFromScheduleWagons(schedule.trainSet.wagons)
: [],
[schedule?.trainSet?.wagons],
);
useEffect(() => {
if (!containerUnits.length || !containerSlots.length) return;
setContainerPlacements((current) => {
if (current.length && current.some((p) => p.containerNumber?.trim())) {
return current;
}
const autoFilled = autoFillPlacements(containerUnits, containerSlots);
if (savedPlacementsFromSchedule.length) {
return mergePlacementsWithSaved(autoFilled, savedPlacementsFromSchedule);
}
if (current.length) return current;
return autoFilled;
});
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
if (detailQuery.isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
if (!schedule || !scheduleId) {
return (
<Text c="dimmed" py="xl">
Schedule not found
</Text>
);
}
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const handleAssign = async () => {
if (!allSelectedIds.length) return;
if (hasContainerStep) {
const issues = validateLocalPlacements(containerUnits, containerPlacements);
if (issues.length) {
toast({
title: "Complete container assignments",
description: issues.join(", "),
variant: "destructive",
});
return;
}
}
try {
const result = await assign.mutateAsync({
id: scheduleId,
freightType,
payload: {
bookingIds: allSelectedIds,
forceAssign,
containerPlacements: hasContainerStep ? containerPlacements : undefined,
},
});
toast({ title: "Bookings assigned — wagons auto-pinned" });
const refreshed = await detailQuery.refetch();
const saved = refreshed.data?.trainSet?.wagons
? placementsFromScheduleWagons(refreshed.data.trainSet.wagons)
: [];
if (saved.length) {
setContainerPlacements(saved);
}
autoPreviewedRef.current = false;
setActiveStep(finalizeStep);
if (result.deferredBookings?.length) {
toast({
title: "Partial assignment",
description: `${result.deferredBookings.length} booking(s) deferred to next train`,
});
}
} catch (err) {
toast({
title: "Assign failed",
description: parseError(err, "Could not assign"),
variant: "destructive",
});
}
};
const handleUnassign = async (bookingId: string) => {
try {
await unassign.mutateAsync({ id: scheduleId, bookingId });
toast({ title: "Booking unassigned" });
setSelectedBookingIds((ids) => ids.filter((id) => id !== bookingId));
setPreviewResult(null);
autoPreviewedRef.current = false;
} catch (err) {
toast({
title: "Unassign failed",
description: parseError(err, "Could not unassign"),
variant: "destructive",
});
}
};
const stepLabels = [
"Bookings",
"Wagon plan",
...(hasContainerStep ? ["Containers"] : []),
"Finalize",
];
return (
<Stack gap="lg">
<Button
component={Link}
to="/dashboard/operations/train-scheduling-v2"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedules
</Button>
<Card
radius={schedulingWorkflow.card.radius}
padding={schedulingWorkflow.card.padding}
withBorder
style={{
background: "linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start">
<Paper p="sm" radius="xl" bg="teal.1">
<Train size={24} color="var(--mantine-color-teal-7)" />
</Paper>
<Stack gap={4}>
<Title order={3}>{schedule.route?.name ?? "Train schedule"}</Title>
<Text size="sm" c="dimmed">
{schedule.originStation?.label ?? schedule.originStation?.code} {" "}
{schedule.destinationStation?.label ?? schedule.destinationStation?.code}
</Text>
<Text size="xs" c="dimmed">
Departure {new Date(schedule.scheduledDepartureDate).toLocaleString()}
</Text>
</Stack>
</Group>
<Group gap="sm">
{schedule.status !== "DISPATCHED" ? (
<Button variant="light" size="compact-sm" onClick={() => setMaintenanceOpen(true)}>
Reschedule train
</Button>
) : null}
<FreightTypeBadge freightType={schedule.freightType} />
<ScheduleStatusBadge status={schedule.status} />
</Group>
</Group>
<Divider my="md" />
<Group gap="xl">
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
Locomotive
</Text>
<Text size="sm" fw={600}>
{schedule.trainSet?.locomotive?.code ?? "—"}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
Bookings
</Text>
<Text size="sm" fw={600}>
{schedule.bookings?.length ?? 0}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
Wagons
</Text>
<Text size="sm" fw={600}>
{schedule.trainSet?.wagonCount ?? displayWagonPlan.length} ·{" "}
{schedule.trainSet?.totalWeightTons ?? 0}T
</Text>
</Stack>
{previewResult ? (
<Badge variant="light" color={previewResult.valid ? "green" : "red"}>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
</Group>
</Card>
<Card radius={schedulingWorkflow.card.radius} padding={schedulingWorkflow.card.padding} withBorder>
<Stack gap="lg">
<SchedulingWorkflowHeader
title="Scheduling workflow"
subtitle={`${schedule.route?.name ?? "Train schedule"} · ${schedule.originStation?.code ?? ""}${schedule.destinationStation?.code ?? ""}`}
activeStep={activeStep}
totalSteps={stepLabels.length}
stepLabel={stepLabels[activeStep] ?? ""}
stepDescription={
activeStep === 0
? "Select & preview"
: activeStep === 1
? "Allocations"
: hasContainerStep && activeStep === 2
? "Map units"
: "Depart"
}
stepIcon={
activeStep === 0
? "package"
: activeStep === 1
? "layout"
: hasContainerStep && activeStep === 2
? "container"
: "check"
}
/>
<Stepper
active={activeStep}
onStepClick={setActiveStep}
color={schedulingWorkflow.stepper.color}
iconSize={schedulingWorkflow.stepper.iconSize}
size={schedulingWorkflow.stepper.size}
>
<Stepper.Step label="Bookings" description="Select & preview">
<Stack gap="md" mt="lg">
<ScheduleBookingsStep
assignedBookings={(schedule.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
selectedIds={allSelectedIds}
onSelectionChange={(ids) => {
const assigned = new Set(assignedIds);
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
}}
assignedIds={assignedIds}
freightType={freightType}
canRemove={canModifyBookings}
onRemove={handleUnassign}
/>
{canEditBookings ? (
<Group align="center" wrap="wrap">
<Button
variant="filled"
loading={preview.isPending}
onClick={() => void runPreview()}
>
Preview plan
</Button>
<Checkbox
label="Force assign (bypass hold/overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
/>
</Group>
) : null}
{previewResult ? (
<Stack gap="sm">
<ScheduleWarningsAlert
violations={previewResult.violations}
warnings={previewResult.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult.fleetAvailability}
deferredBookings={previewResult.deferredBookings}
/>
<PreviewSummary summary={previewResult.summary} />
</Stack>
) : null}
</Stack>
</Stepper.Step>
<Stepper.Step label="Wagon plan" description="Allocations">
<Stack gap="md" mt="lg">
{!displayWagonPlan.length && !previewResult ? (
<Text size="sm" c="dimmed">
Run a preview from the Bookings step to generate the wagon plan.
</Text>
) : null}
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
) : (
<Button variant="light" onClick={() => setActiveStep(2)}>
Continue to containers
</Button>
)}
<Button variant="default" onClick={() => void runPreview()}>
Refresh preview
</Button>
</Group>
) : null}
</Stack>
</Stepper.Step>
{hasContainerStep ? (
<Stepper.Step label="Containers" description="Map units">
<Stack gap="md" mt="lg">
{!containerUnits.length ? (
<Paper p="md" radius="xl" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run preview from the Bookings step to load container units for numbering.
</Text>
</Paper>
) : (
<ContainerPlacementGrid
units={containerUnits}
containerSlots={containerSlots}
placements={containerPlacements}
onChange={setContainerPlacements}
/>
)}
{canEditBookings ? (
<Group>
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
Skip to finalize
</Button>
</Group>
) : null}
</Stack>
</Stepper.Step>
) : null}
<Stepper.Step label="Finalize" description="Depart">
<Stack gap="md" mt="lg">
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Finalize moves the schedule to SCHEDULED. Dispatch begins rail movement.
</Text>
</Paper>
<Group>
{canFinalize ? (
<Button
color="green"
loading={finalize.isPending}
onClick={async () => {
try {
await finalize.mutateAsync(scheduleId);
toast({ title: "Schedule finalized" });
} catch (err) {
toast({
title: "Finalize failed",
description: parseError(err, "Could not finalize"),
variant: "destructive",
});
}
}}
>
Finalize schedule
</Button>
) : null}
{canDispatch ? (
<Button
color="blue"
loading={dispatch.isPending}
onClick={async () => {
try {
await dispatch.mutateAsync(scheduleId);
toast({ title: "Train dispatched" });
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
}}
>
Dispatch train
</Button>
) : null}
</Group>
</Stack>
</Stepper.Step>
</Stepper>
</Stack>
</Card>
{scheduleId ? (
<RescheduleTrainDialog
scheduleId={scheduleId}
currentBookingIds={(schedule.bookings ?? []).map((b) => b.id)}
opened={maintenanceOpen}
onClose={() => setMaintenanceOpen(false)}
onComplete={() => void detailQuery.refetch()}
/>
) : null}
</Stack>
);
}

View File

@@ -0,0 +1,438 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { Train } from "lucide-react";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import {
FreightTypeBadge,
ScheduleStatusBadge,
} from "@/components/trainScheduling/ScheduleStatusBadge";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRoutes } from "@/hooks/useRoutes";
import {
useAvailableLocomotives,
useScheduleList,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type { FreightType, TrainScheduleListItem } from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
const formatDate = (value?: string | null) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("en", {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
export default function TrainScheduleV2ListPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [createOpen, setCreateOpen] = useState(false);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveId, setLocomotiveId] = useState("");
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives();
const { create, cancel } = useScheduleMutations();
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.isActive),
[routesQuery.data],
);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return (schedulesQuery.data ?? []).filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
if (!query) return true;
const haystack = [
s.trainNumber,
s.routeName,
s.origin,
s.destination,
s.locomotive?.code,
s.freightType,
s.status,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [schedulesQuery.data, search, statusFilter, freightFilter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filtered.slice(start, start + pagination.pageSize);
}, [filtered, pagination]);
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "date",
header: "Departure",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatDate(row.original.scheduleDate),
},
{
id: "route",
header: "Route",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.routeName ?? "—",
},
{
id: "corridor",
header: "Corridor",
meta: { headerClassName, cellClassName },
cell: ({ row }) => `${row.original.origin ?? "—"}${row.original.destination ?? "—"}`,
},
{
id: "freight",
header: "Freight",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
},
{
id: "loco",
header: "Locomotive",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.locomotive?.code ?? "—",
},
{
id: "metrics",
header: "Bookings / Wagons",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
`${row.original.bookingsCount} / ${row.original.wagonCount} · ${row.original.totalWeightTons}T`,
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <ScheduleStatusBadge status={row.original.status} />,
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group gap={6} justify="flex-end" wrap="nowrap">
<Button
variant="light"
size="compact-sm"
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`)
}
>
Open
</Button>
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
<Button
variant="light"
color="red"
size="compact-sm"
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({
id: row.original.id,
freightType: row.original.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
>
Cancel
</Button>
) : null}
</Group>
),
},
];
}, [navigate, cancel.isPending, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || !locomotiveId) {
toast({ title: "Select route, date, and locomotive", variant: "destructive" });
return;
}
try {
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
});
toast({ title: "Train schedule created" });
setCreateOpen(false);
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
} catch (err) {
toast({
title: "Create failed",
description: parseError(err, "Could not create schedule"),
variant: "destructive",
});
}
};
const tableStatus = schedulesQuery.isLoading
? "loading"
: schedulesQuery.isError
? "error"
: "success";
return (
<Stack gap="md">
<Paper
p="lg"
radius={schedulingWorkflow.card.radius}
withBorder
style={{
background:
"linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
}}
>
<Group gap="md" align="center">
<ThemeIcon size={48} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
<Train size={24} />
</ThemeIcon>
<Stack gap={2}>
<Title order={3}>Train Schedules</Title>
<Text size="sm" c="dimmed">
Plan departures, allocate bookings, and dispatch trains across corridors.
</Text>
</Stack>
</Group>
</Paper>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search schedules…"
addLabel="Create schedule"
onAdd={() => setCreateOpen(true)}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<>
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "CANCELLED", label: "Cancelled" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={freightFilter}
onChange={(v) => v && setFreightFilter(v)}
data={[
{ value: "ALL", label: "All freight" },
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
]}
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</>
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={paged}
status={tableStatus}
emptyMessage="No train schedules found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "schedules" } }}
/>
)}
/>
) : (
<Stack gap={0}>
{!paged.length ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
No train schedules found
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{paged.map((schedule) => (
<Card key={schedule.id} radius="lg" padding="lg" withBorder>
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600} size="sm">
{schedule.routeName ?? "Train schedule"}
</Text>
<ScheduleStatusBadge status={schedule.status} />
</Group>
<Text size="sm" c="dimmed">
{formatDate(schedule.scheduleDate)}
</Text>
<Text size="xs" c="dimmed">
{schedule.origin} {schedule.destination}
</Text>
<Group gap={6}>
<FreightTypeBadge freightType={schedule.freightType} />
<Text size="xs" c="dimmed">
{schedule.bookingsCount} bookings · {schedule.wagonCount} wagons
</Text>
</Group>
<Button
variant="light"
size="compact-sm"
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
>
Open
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filtered.length}
itemLabel="schedules"
onPaginationChange={setPagination}
/>
</Stack>
)}
</Stack>
</Card>
<Modal
opened={createOpen}
onClose={() => setCreateOpen(false)}
title={<Text fw={600}>Create train schedule</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Schedules support both container and bulk bookings once assigned.
</Text>
<Select
label="Route"
placeholder="Select route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<TextInput
label="Departure date"
type="datetime-local"
value={scheduleDate ? scheduleDate.slice(0, 16) : ""}
onChange={(e) => {
const raw = e.currentTarget.value;
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
/>
<Select
label="Locomotive"
placeholder="Select locomotive"
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button color="green" loading={create.isPending} onClick={handleCreate}>
Create
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { Button, Card, Group, NumberInput, Stack, Text, Title } from "@mantine/core";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
useEffect(() => {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
setForm(rules);
} catch {
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
} finally {
setLoading(false);
}
})();
}, [toast]);
const handleSave = async () => {
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules({
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
maxTrainWeightTons: Number(form.maxTrainWeightTons),
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
});
setForm(updated);
toast({ title: "Train scheduling rules saved" });
} catch {
toast({ title: "Failed to save rules", variant: "destructive" });
} finally {
setSaving(false);
}
};
return (
<Stack gap="lg" maw={720}>
<Stack gap={4}>
<Title order={3}>Train scheduling rules</Title>
<Text size="sm" c="dimmed">
Global limits applied when previewing and assigning bookings to trains.
</Text>
</Stack>
<Card radius="xl" padding="lg" withBorder>
<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: Number(value) }))
}
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: Number(value) }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
}
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: Number(value),
}))
}
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: Number(value),
}))
}
min={0}
disabled={loading}
/>
<Group justify="flex-end">
<Button color="teal" loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -1,36 +1,110 @@
import { useParams } from 'react-router-dom';
import { useTrain } from '@/hooks/useTrains';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// import { Skeleton } from '@/components/ui/skeleton';
// import { AssignWagonDialog } from '@/components/AssignWagonDialog';
// import { WagonsTable } from '@/components/WagonsTable';
import { Card, CardContent, CardHeader, CardTitle, Skeleton } from '@edr/ui-common';
import { AssignWagonDialog } from '@/components/wagons/AssignWagonDialog';
import { WagonsTable } from '@/components/wagons/WagonsTable';
import { useParams, Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Badge, Button, Card, Group, Loader, SimpleGrid, Stack, Text } from "@mantine/core";
import { AssignWagonDialog } from "@/components/wagons/AssignWagonDialog";
import { WagonsTable } from "@/components/wagons/WagonsTable";
import { useTrain } from "@/hooks/useTrains";
export default function TrainDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: train, isLoading } = useTrain(id!);
if (isLoading) return <Skeleton className="h-96 w-full" />;
if (!train) return <div>Train not found</div>;
if (isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
if (!train) {
return (
<Text c="dimmed" py="xl">
Train not found
</Text>
);
}
const title = train.trainNumber || train.code;
const subtitle = train.trainName || "Unnamed train";
return (
<div className="space-y-6">
<Card>
<CardHeader><CardTitle>{train.trainNumber || train.code} - {train.trainName || 'Unnamed'}</CardTitle></CardHeader>
<CardContent className="grid md:grid-cols-2 gap-4">
<div><span className="font-medium">Status:</span> {train.status}</div>
<div><span className="font-medium">Capacity:</span> {train.capacityTons} tons</div>
<div><span className="font-medium">Origin Station:</span> {train.originStationId || '-'}</div>
<div><span className="font-medium">Destination:</span> {train.destinationStationId || '-'}</div>
</CardContent>
<Stack gap="md">
<Button
component={Link}
to="/dashboard/trains"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to trains
</Button>
<Card radius="lg" padding="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={4}>
<Text fw={700} size="lg">
{title}
</Text>
<Text size="sm" c="dimmed">
{subtitle}
</Text>
</Stack>
<Badge variant="light" color="gray" size="lg">
{train.status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
<Stack gap={2}>
<Text size="xs" c="dimmed">
Code
</Text>
<Text size="sm" fw={500}>
{train.code}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Capacity
</Text>
<Text size="sm" fw={500}>
{train.capacityTons} tons
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Locomotive
</Text>
<Text size="sm" fw={500}>
{train.locomotiveNumber || "—"}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Origin station
</Text>
<Text size="sm" fw={500}>
{train.originStationId || "—"}
</Text>
</Stack>
</SimpleGrid>
</Stack>
</Card>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Wagons</h2>
<AssignWagonDialog trainId={train.id} />
</div>
<WagonsTable trainId={train.id} />
</div>
<Card radius="lg" padding="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>Assigned wagons</Text>
<AssignWagonDialog trainId={train.id} />
</Group>
<WagonsTable trainId={train.id} />
</Stack>
</Card>
</Stack>
);
}
}

View File

@@ -1,109 +0,0 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { useWagonTypes } from '@/hooks/use-wagon-types';
const wagonSchema = z.object({
wagonNumber: z.string().min(1, 'Required'),
wagonTypeId: z.string().min(1, 'Required'),
maxPayloadWeight: z.coerce.number().min(0),
});
type WagonFormValues = z.infer<typeof wagonSchema>;
interface WagonFormProps {
initialValues?: Partial<WagonFormValues>;
onSubmit: (values: WagonFormValues) => void;
}
export function WagonForm({ initialValues, onSubmit }: WagonFormProps) {
const { data: wagonTypes, isLoading: loadingTypes } = useWagonTypes();
const form = useForm<WagonFormValues>({
resolver: zodResolver(wagonSchema),
defaultValues: {
wagonNumber: initialValues?.wagonNumber || '',
wagonTypeId: initialValues?.wagonTypeId || '',
maxPayloadWeight: initialValues?.maxPayloadWeight || 0,
},
});
const selectedTypeId = form.watch('wagonTypeId');
// Autofill maxPayloadWeight when type changes
useEffect(() => {
if (selectedTypeId && wagonTypes) {
const type = wagonTypes.find((t) => t.id === selectedTypeId);
if (type) {
// Only autofill if it's a new selection and field is at default or empty
const currentWeight = form.getValues('maxPayloadWeight');
if (!initialValues?.wagonTypeId || selectedTypeId !== initialValues.wagonTypeId) {
form.setValue('maxPayloadWeight', Number(type.capacityTons));
}
}
}
}, [selectedTypeId, wagonTypes, form, initialValues?.wagonTypeId]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="wagonNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Wagon Number</FormLabel>
<FormControl>
<Input placeholder="e.g. W12345" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="wagonTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Wagon Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingTypes}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{wagonTypes?.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxPayloadWeight"
render={({ field }) => (
<FormItem>
<FormLabel>Max Payload Weight (Tons)</FormLabel>
<FormControl>
<Input type="number" step="0.001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -1,31 +0,0 @@
import { useWagons } from '@/hooks/useWagons';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function WagonsPage() {
const { data: wagons, isLoading } = useWagons();
if (isLoading) return <div>Loading wagons...</div>;
return (
<Card>
<CardHeader><CardTitle>All Wagons</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Train</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{wagons?.map((w:any) => (
<TableRow key={w.id}>
<TableCell>{w.wagonNumber}</TableCell>
<TableCell>{w.wagonTypeId}</TableCell>
<TableCell>{w.trainId || 'Unassigned'}</TableCell>
<TableCell><Badge variant="outline">{w.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -9,6 +9,8 @@ export interface BookingListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs */
statuses?: string;
schedulingStatuses?: string;
assignedToSchedule?: "true" | "false";
/** Tab key for React Query cache (not sent to API) */
tab?: string;
// customerId?: string;
@@ -121,6 +123,8 @@ export const bookingsService = {
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy;
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
@@ -210,6 +214,23 @@ export const bookingsService = {
cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
B.BASE,
payload,
);
const data = unwrap(response.data) as { booking?: BookingDetail };
return (data.booking ?? data) as BookingDetail;
},
getReferenceData: async () => {
const response = await client.get(B.REFERENCE_DATA);
return unwrap(response.data);
},
governmentExpedite: (id: string) =>
postBooking<BookingDetail>(B.GOVERNMENT_EXPEDITE(id)),
};
async function ensurePdfBlob(blob: Blob): Promise<Blob> {

View File

@@ -0,0 +1,51 @@
import { cargoService, type Cargo } from "@/services/cargoService";
import { containerService, type Container } from "@/services/containerService";
import { locomotivesService, type Locomotive } from "@/services/locomotives.service";
import { trainService, type Train } from "@/services/trains.service";
import { wagonService, type Wagon } from "@/services/wagon.service";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
export type FleetRecord = Locomotive | Train | Wagon | Container | Cargo;
const listHandlers: Record<FleetResourceSlug, () => Promise<FleetRecord[]>> = {
locomotives: () => locomotivesService.getAll().then((r) => r.data),
trains: () => trainService.getAll().then((r) => r.data),
wagons: () => wagonService.getAll().then((r) => r.data),
containers: () => containerService.getAll().then((r) => r.data),
cargoes: () => cargoService.getAll().then((r) => r.data),
};
const createHandlers: Record<FleetResourceSlug, (data: Record<string, unknown>) => Promise<unknown>> = {
locomotives: (data) => locomotivesService.create(data),
trains: (data) => trainService.create(data),
wagons: (data) => wagonService.create(data),
containers: (data) => containerService.create(data),
cargoes: (data) => cargoService.create(data),
};
const updateHandlers: Record<
FleetResourceSlug,
(id: string, data: Record<string, unknown>) => Promise<unknown>
> = {
locomotives: (id, data) => locomotivesService.update(id, data),
trains: (id, data) => trainService.update(id, data),
wagons: (id, data) => wagonService.update(id, data),
containers: (id, data) => containerService.update(id, data),
cargoes: (id, data) => cargoService.update(id, data),
};
const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>> = {
locomotives: (id) => locomotivesService.decommission(id),
trains: (id) => trainService.delete(id),
wagons: (id) => wagonService.delete(id),
containers: (id) => containerService.delete(id),
cargoes: (id) => cargoService.delete(id),
};
export const fleetService = {
list: (slug: FleetResourceSlug) => listHandlers[slug](),
create: (slug: FleetResourceSlug, data: Record<string, unknown>) => createHandlers[slug](data),
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
updateHandlers[slug](id, data),
remove: (slug: FleetResourceSlug, id: string) => removeHandlers[slug](id),
};

View File

@@ -2,14 +2,18 @@ import { api as client } from '../auth/http';
import { unwrap } from '@/utils/endpoint';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
AssignBookingsPayload,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
LocomotiveRecord,
PinWagonsPayload,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
TrainSchedulingGlobalRules,
YardOption,
} from '@/types/trainScheduling';
@@ -17,22 +21,33 @@ interface BookingReferenceDataResponse {
yard?: Array<YardOption & { label?: string }>;
}
const pathsFor = (freightType?: FreightType) =>
freightType === "BULK"
? URL_CONSTANTS.TRAIN_SCHEDULING.BULK
: URL_CONSTANTS.TRAIN_SCHEDULING.CONTAINER;
export const trainSchedulingService = {
getEligibleBookings: async (
filters?: TrainScheduleFilters,
freightType?: FreightType,
): Promise<EligibleContainerBookingsResponse> => {
const useUnified = !freightType || freightType === "MIXED";
const response = await client.get<EligibleContainerBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS,
{ params: filters },
useUnified
? URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS
: pathsFor(freightType).ELIGIBLE_BOOKINGS,
{ params: { ...filters, ...(freightType && freightType !== "MIXED" ? { freightType } : {}) } },
);
return unwrap(response.data);
},
preview: async (
payload: TrainSchedulePreviewPayload,
freightType?: FreightType,
): Promise<TrainSchedulePreviewResponse> => {
const useUnified = !freightType || freightType === "MIXED";
const response = await client.post<TrainSchedulePreviewResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW,
useUnified ? URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW : pathsFor(freightType).PREVIEW,
payload,
);
return unwrap(response.data);
@@ -40,31 +55,92 @@ export const trainSchedulingService = {
createSchedule: async (
payload: CreateTrainSchedulePayload,
freightType?: FreightType,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
pathsFor(freightType).SCHEDULES,
payload,
);
return unwrap(response.data);
},
listSchedules: async (): Promise<TrainScheduleListItem[]> => {
listSchedules: async (
freightType: FreightType = "CONTAINER",
): Promise<TrainScheduleListItem[]> => {
const response = await client.get<TrainScheduleListItem[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
pathsFor(freightType === "MIXED" ? undefined : freightType).SCHEDULES,
);
return unwrap(response.data);
},
getScheduleById: async (id: string): Promise<TrainScheduleDetail> => {
getScheduleById: async (
id: string,
freightType?: FreightType,
): Promise<TrainScheduleDetail> => {
const response = await client.get<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_BY_ID(id),
pathsFor(freightType === "MIXED" ? undefined : freightType).SCHEDULE_BY_ID(id),
);
return unwrap(response.data);
},
cancelSchedule: async (id: string): Promise<TrainScheduleDetail> => {
assignBookings: async (
scheduleId: string,
payload: AssignBookingsPayload,
freightType?: FreightType,
): Promise<TrainScheduleDetail> => {
const useUnified = !freightType || freightType === "MIXED";
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_SCHEDULE(id),
useUnified
? URL_CONSTANTS.TRAIN_SCHEDULING.ASSIGN_BOOKINGS(scheduleId)
: pathsFor(freightType).ASSIGN_BOOKINGS(scheduleId),
payload,
);
return unwrap(response.data);
},
unassignBooking: async (
scheduleId: string,
bookingId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.delete<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.UNASSIGN_BOOKING(scheduleId, bookingId),
);
return unwrap(response.data);
},
pinWagons: async (
scheduleId: string,
payload: PinWagonsPayload,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.PIN_WAGONS(scheduleId),
payload,
);
return unwrap(response.data);
},
finalizeSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.FINALIZE(scheduleId),
{},
);
return unwrap(response.data);
},
dispatchSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
{},
);
return unwrap(response.data);
},
cancelSchedule: async (
id: string,
freightType: FreightType = "CONTAINER",
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
pathsFor(freightType === "MIXED" ? undefined : freightType).CANCEL_SCHEDULE(id),
{},
);
return unwrap(response.data);
@@ -77,6 +153,81 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
previewReschedule: async (
scheduleId: string,
payload: {
incomingBookingIds: string[];
trigger: string;
reason?: string;
newDepartureDate?: string;
},
) => {
const response = await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.RESCHEDULE_PREVIEW(scheduleId),
payload,
);
return unwrap(response.data);
},
executeReschedule: async (
scheduleId: string,
payload: {
incomingBookingIds: string[];
trigger: string;
reason?: string;
newDepartureDate?: string;
finalBookingIds: string[];
displacedBookingIds: string[];
},
) => {
const response = await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.RESCHEDULE_EXECUTE(scheduleId),
payload,
);
return unwrap(response.data);
},
maintenanceReschedule: async (
scheduleId: string,
payload: {
incomingBookingIds: string[];
newDepartureDate: string;
reason?: string;
},
) => {
const response = await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MAINTENANCE(scheduleId),
{ ...payload, trigger: "TRAIN_MAINTENANCE" },
);
return unwrap(response.data);
},
getGlobalRules: async (): Promise<TrainSchedulingGlobalRules> => {
const response = await client.get<TrainSchedulingGlobalRules>(
URL_CONSTANTS.TRAIN_SCHEDULING.GLOBAL_RULES,
);
return unwrap(response.data);
},
updateGlobalRules: async (
payload: Partial<
Pick<
TrainSchedulingGlobalRules,
| "maxTrainLengthMeters"
| "maxTrainWeightTons"
| "maxWagonsPerTrain"
| "max20ftContainerWeightTons"
| "max20ftPairWeightDiffTons"
>
>,
): Promise<TrainSchedulingGlobalRules> => {
const response = await client.patch<TrainSchedulingGlobalRules>(
URL_CONSTANTS.TRAIN_SCHEDULING.GLOBAL_RULES,
payload,
);
return unwrap(response.data);
},
getStations: async (): Promise<YardOption[]> => {
const response = await client.get<BookingReferenceDataResponse>(
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,

View File

@@ -1,3 +1,5 @@
import type { Freight } from "@edr/types";
import { api as apiClient } from "../auth/http";
export interface Wagon {
@@ -8,7 +10,8 @@ export interface Wagon {
sequenceNumber: number | null;
tareWeight: number;
maxPayloadWeight: number;
status: string;
status: Freight.WagonStatus;
readiness: Freight.WagonReadiness;
notes?: string;
}

View File

@@ -87,7 +87,9 @@ export interface BookingDetail {
id: string;
reference: string;
// customerId: string;
companyId: string;
companyId?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;
status: BookingStatus;
scheduledDate: string;
totalAmount: number;
@@ -100,6 +102,12 @@ export interface BookingDetail {
isHazardous: boolean;
allowConsolidation: boolean;
priorityScore: number;
schedulingStatus?: string;
holdExpiresAt?: string | null;
holdStartedAt?: string | null;
wagonsRequired?: number | null;
scheduledAt?: string | null;
trainScheduleId?: string | null;
pnrCode?: string | null;
firstMilePickupAddress?: string | null;
lastMileDeliveryAddress?: string | null;
@@ -114,7 +122,7 @@ export interface BookingDetail {
company?: BookingNamedRef;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string };
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
@@ -143,5 +151,11 @@ export interface BookingListRow {
originLabel: string;
destinationLabel: string;
priorityScore: number;
schedulingStatus?: string;
serviceTypeLabel?: string;
serviceTypeBonus?: number;
trainScheduleId?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;
createdAt: string;
}

View File

@@ -1,3 +1,19 @@
export type FreightType = "CONTAINER" | "BULK" | "MIXED";
export type SchedulingStatus =
| "NOT_SCHEDULED"
| "HOLDING"
| "ELIGIBLE"
| "SCHEDULED"
| "DISPATCHED";
export type TrainScheduleStatus =
| "DRAFT"
| "SCHEDULED"
| "DISPATCHED"
| "ARRIVED"
| "CANCELLED";
export interface YardOption {
id: string;
name: string;
@@ -8,6 +24,7 @@ export interface YardOption {
export interface EligibleContainerBooking {
id: string;
reference: string;
freightType?: FreightType | string;
customer: string;
containerType: string;
quantity: number;
@@ -16,6 +33,8 @@ export interface EligibleContainerBooking {
destination: string;
preferredDepartureDate: string;
status: string;
schedulingStatus?: SchedulingStatus;
priorityScore?: number;
}
export interface EligibleContainerBookingsResponse {
@@ -27,6 +46,7 @@ export interface WagonPlanAllocation {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
loadType?: string;
}
export interface WagonPlanRow {
@@ -34,21 +54,76 @@ export interface WagonPlanRow {
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
slotLoadType?: "CONTAINER" | "BULK";
wagonTypeCode?: string;
allocations: WagonPlanAllocation[];
}
export interface ContainerUnitRow {
bookingId: string;
bookingReference: string;
bookingContainerId: string;
unitIndex: number;
containerTypeId: string;
containerTypeCode: string;
label: string;
grossWeightTons: number;
sizeFt?: number;
wagonsPerUnit?: number;
containersPerWagon?: number;
teuSlots?: number;
}
export interface ContainerPlacement {
bookingContainerId: string;
unitIndex: number;
sequenceNo: number;
containerId?: string;
containerNumber?: string;
sealNumber?: string;
}
export interface FleetAvailabilityRow {
wagonTypeId: string;
wagonTypeCode: string;
needed: number;
available: number;
shortfall: number;
}
export interface DeferredBookingRow {
id: string;
reference: string;
reason: string;
}
export interface TrainSchedulingGlobalRules {
id: string;
maxTrainLengthMeters: number;
maxTrainWeightTons: number;
maxWagonsPerTrain: number;
max20ftContainerWeightTons: number;
max20ftPairWeightDiffTons: number;
}
export interface TrainSchedulePreviewResponse {
valid: boolean;
violations: string[];
warnings: string[];
fleetAvailability?: FleetAvailabilityRow[];
deferredBookings?: DeferredBookingRow[];
summary: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
freightMode?: FreightType;
};
bookingIds: string[];
wagonPlan: WagonPlanRow[];
containerUnits?: ContainerUnitRow[];
containerSlotSequenceNos?: number[];
}
export interface LocomotiveRecord {
@@ -57,16 +132,18 @@ export interface LocomotiveRecord {
name?: string | null;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'OUT_OF_SERVICE';
locomotiveType?: 'DIESEL' | 'ELECTRIC';
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
locomotiveType?: "DIESEL" | "ELECTRIC";
}
export interface TrainScheduleListItem {
id: string;
scheduleDate: string;
trainNumber?: string | null;
routeName?: string | null;
origin: string | null;
destination: string | null;
freightType?: FreightType | null;
locomotive:
| {
id: string;
@@ -78,18 +155,47 @@ export interface TrainScheduleListItem {
totalWeightTons: number;
totalLengthMeters: number;
bookingsCount: number;
status: string;
status: TrainScheduleStatus | string;
}
export interface TrainScheduleWagonAllocation {
id: string;
bookingId: string;
bookingReference: string | null;
allocatedWeightTons: number;
loadType?: string | null;
status?: string;
containerItems?: Array<{
id: string;
containerNumber: string | null;
containerTypeId: string;
grossWeightTons: number | null;
containerId?: string | null;
positionOnWagon?: number | null;
bookingContainerId?: string | null;
}>;
bulkLoad?: {
id: string;
weightTons: number;
cargoDescription: string | null;
} | null;
}
export interface TrainScheduleDetail {
id: string;
status: string;
status: TrainScheduleStatus | string;
warnings?: string[];
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
direction?: string | null;
route?: {
id: string;
name: string;
} | null;
scheduledDepartureDate: string;
scheduledArrivalDate?: string | null;
actualDepartureAt?: string | null;
originStation?: {
id: string;
label?: string;
@@ -106,31 +212,29 @@ export interface TrainScheduleDetail {
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
locomotive?: {
id: string;
code: string;
name?: string | null;
status: string;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
locomotive?: {
id: string;
code: string;
name?: string | null;
status: string;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
wagons: Array<{
id: string;
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
status?: string;
physicalWagonId?: string | null;
physicalWagonNumber?: string | null;
wagonType?: {
id: string;
code: string;
name: string;
} | null;
allocations: Array<{
id: string;
bookingId: string;
bookingReference: string | null;
allocatedWeightTons: number;
}>;
allocations: TrainScheduleWagonAllocation[];
}>;
} | null;
bookings: Array<{
@@ -139,13 +243,15 @@ export interface TrainScheduleDetail {
customer: string | null;
weightTons: number;
status: string | null;
schedulingStatus?: SchedulingStatus | null;
}>;
warnings?: string[];
}
export interface TrainScheduleFilters {
originStationId?: string;
destinationStationId?: string;
scheduleDate?: string;
schedulingStatus?: SchedulingStatus;
}
export interface TrainSchedulePreviewPayload {
@@ -153,10 +259,53 @@ export interface TrainSchedulePreviewPayload {
scheduleDate: string;
originStationId: string;
destinationStationId: string;
targetScheduleId?: string;
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}
export interface RescheduleBookingSummary {
id: string;
reference: string;
isGovernment: boolean;
priorityScore: number;
governmentInstitution?: string | null;
}
export interface ReschedulePlan {
scheduleId: string;
trigger: string;
retained: RescheduleBookingSummary[];
displaced: RescheduleBookingSummary[];
readmitted: RescheduleBookingSummary[];
finalBookingIds: string[];
warnings: string[];
}
export interface CreateTrainSchedulePayload {
routeId: string;
scheduleDate: string;
locomotiveId: string;
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}
export interface AssignBookingsPayload {
bookingIds: string[];
forceAssign?: boolean;
containerPlacements?: ContainerPlacement[];
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}
export interface PinWagonAssignment {
trainSetWagonId: string;
physicalWagonId: string;
}
export interface PinWagonsPayload {
assignments: PinWagonAssignment[];
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { compareSchedulingPriority } from "./compareSchedulingPriority";
describe("compareSchedulingPriority", () => {
it("ranks government above commercial regardless of date gap", () => {
const gov = {
isGovernment: true,
priorityScore: 100,
scheduledDate: "2026-06-25T08:00:00.000Z",
};
const commercial = {
isGovernment: false,
priorityScore: 50000,
scheduledDate: "2026-06-20T08:00:00.000Z",
};
expect(compareSchedulingPriority(gov, commercial)).toBeLessThan(0);
});
it("sorts by priority score within same tier", () => {
const high = { priorityScore: 100, scheduledDate: "2026-06-20T08:00:00.000Z" };
const low = { priorityScore: 10, scheduledDate: "2026-06-20T08:00:00.000Z" };
expect(compareSchedulingPriority(high, low)).toBeLessThan(0);
});
});

View File

@@ -0,0 +1,19 @@
export interface SchedulingPriorityRow {
isGovernment?: boolean;
priorityScore?: number;
scheduledDate: string;
}
/** Government first, then priority score, then earliest scheduled date. */
export function compareSchedulingPriority(
a: SchedulingPriorityRow,
b: SchedulingPriorityRow,
): number {
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
if (govDiff !== 0) return govDiff;
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { BookingListRow } from "@/types/booking";
import { groupBookingsByThreeHourWindow } from "./groupBookingsByThreeHourWindow";
function row(
id: string,
scheduledDate: string,
priorityScore: number,
): BookingListRow {
return {
id,
reference: id,
customerLabel: "Customer",
status: "PAID",
scheduledDate,
totalAmount: 1000,
paymentCurrency: "ETB",
paymentStatus: "PAID",
tradeDirection: "IMPORT",
freightType: "CONTAINER",
originLabel: "Djibouti",
destinationLabel: "Addis",
priorityScore,
createdAt: scheduledDate,
};
}
describe("groupBookingsByThreeHourWindow", () => {
it("buckets bookings into UTC 3-hour windows", () => {
const buckets = groupBookingsByThreeHourWindow([
row("a", "2026-06-20T05:30:00.000Z", 10),
row("b", "2026-06-20T05:45:00.000Z", 20),
row("c", "2026-06-20T08:00:00.000Z", 30),
]);
expect(buckets).toHaveLength(2);
expect(buckets[0]?.bookings.map((b) => b.id)).toEqual(["b", "a"]);
expect(buckets[1]?.bookings.map((b) => b.id)).toEqual(["c"]);
expect(buckets[0]?.label).toContain("03:00");
expect(buckets[1]?.label).toContain("06:00");
});
it("places midnight boundary bookings in the correct bucket", () => {
const buckets = groupBookingsByThreeHourWindow([
row("late", "2026-06-20T23:45:00.000Z", 5),
row("early", "2026-06-21T00:15:00.000Z", 15),
]);
expect(buckets).toHaveLength(2);
expect(buckets[0]?.bookings[0]?.id).toBe("late");
expect(buckets[1]?.bookings[0]?.id).toBe("early");
});
it("sorts within each bucket by priorityScore descending", () => {
const buckets = groupBookingsByThreeHourWindow([
row("low", "2026-06-20T06:00:00.000Z", 5),
row("high", "2026-06-20T06:30:00.000Z", 50),
row("mid", "2026-06-20T07:00:00.000Z", 25),
]);
expect(buckets[0]?.bookings.map((b) => b.id)).toEqual(["high", "mid", "low"]);
});
});

View File

@@ -0,0 +1,91 @@
import { compareSchedulingPriority } from "./compareSchedulingPriority";
export interface ThreeHourSchedulable {
id: string;
priorityScore?: number;
scheduledDate?: string;
preferredDepartureDate?: string;
isGovernment?: boolean;
}
export interface ThreeHourBookingBucket<T extends ThreeHourSchedulable = ThreeHourSchedulable> {
key: string;
label: string;
start: Date;
end: Date;
bookings: T[];
}
function bucketStart(date: Date): Date {
const start = new Date(date);
start.setUTCMinutes(0, 0, 0);
const hour = start.getUTCHours();
start.setUTCHours(Math.floor(hour / 3) * 3);
return start;
}
function formatBucketLabel(start: Date, end: Date): string {
const dateFmt = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const timeFmt = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "UTC",
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} ${timeFmt.format(end)} UTC`;
}
function getScheduledDate(booking: ThreeHourSchedulable): Date {
const raw = booking.scheduledDate ?? booking.preferredDepartureDate;
if (!raw) return new Date(0);
return new Date(raw);
}
function toPriorityRow(booking: ThreeHourSchedulable) {
return {
isGovernment: booking.isGovernment,
priorityScore: booking.priorityScore,
scheduledDate: booking.scheduledDate ?? booking.preferredDepartureDate ?? "",
};
}
export function groupBookingsByThreeHourWindow<T extends ThreeHourSchedulable>(
bookings: T[],
): ThreeHourBookingBucket<T>[] {
const map = new Map<string, ThreeHourBookingBucket<T>>();
for (const booking of bookings) {
const scheduled = getScheduledDate(booking);
const start = bucketStart(scheduled);
const end = new Date(start);
end.setUTCHours(end.getUTCHours() + 3);
const key = start.toISOString();
const existing = map.get(key);
if (existing) {
existing.bookings.push(booking);
} else {
map.set(key, {
key,
label: formatBucketLabel(start, end),
start,
end,
bookings: [booking],
});
}
}
return [...map.values()]
.map((bucket) => ({
...bucket,
bookings: [...bucket.bookings].sort((a, b) =>
compareSchedulingPriority(toPriorityRow(a), toPriorityRow(b)),
),
}))
.sort((a, b) => a.start.getTime() - b.start.getTime());
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { BookingListRow } from "@/types/booking";
import { groupBookingsForOperationsQueue } from "./groupBookingsForOperationsQueue";
function row(
id: string,
opts: Partial<BookingListRow> = {},
): BookingListRow {
return {
id,
reference: id,
customerLabel: "Customer",
status: "PAID",
scheduledDate: opts.scheduledDate ?? "2026-06-20T08:00:00.000Z",
totalAmount: 1000,
paymentCurrency: "ETB",
paymentStatus: "PAID",
tradeDirection: "IMPORT",
freightType: "CONTAINER",
originLabel: "A",
destinationLabel: "B",
priorityScore: opts.priorityScore ?? 10,
createdAt: "2026-06-01T00:00:00.000Z",
...opts,
};
}
describe("groupBookingsForOperationsQueue", () => {
it("separates government from commercial 3-hour buckets", () => {
const result = groupBookingsForOperationsQueue([
row("c1", { isGovernment: false }),
row("g1", { isGovernment: true, governmentInstitution: "Ministry" }),
]);
expect(result.government).toHaveLength(1);
expect(result.government[0]?.id).toBe("g1");
expect(result.commercial).toHaveLength(1);
expect(result.commercial[0]?.bookings[0]?.id).toBe("c1");
});
});

View File

@@ -0,0 +1,23 @@
import type { BookingListRow } from "@/types/booking";
import { compareSchedulingPriority } from "./compareSchedulingPriority";
import {
groupBookingsByThreeHourWindow,
type ThreeHourBookingBucket,
} from "./groupBookingsByThreeHourWindow";
export interface OperationsQueueGroups {
government: BookingListRow[];
commercial: ThreeHourBookingBucket[];
}
export function groupBookingsForOperationsQueue(
bookings: BookingListRow[],
): OperationsQueueGroups {
const government = bookings
.filter((b) => b.isGovernment)
.sort(compareSchedulingPriority);
const commercial = groupBookingsByThreeHourWindow(
bookings.filter((b) => !b.isGovernment),
);
return { government, commercial };
}

View File

@@ -0,0 +1,33 @@
import { Freight } from "@edr/types";
import type { Wagon } from "@/services/wagon.service";
export function wagonMatchesScheduleDirection(
wagon: Pick<Wagon, "status" | "readiness">,
scheduleDirection?: string | null,
options?: { allowPinned?: boolean },
): boolean {
if (options?.allowPinned) return true;
if (wagon.status !== Freight.WagonStatus.Available) return false;
if (!scheduleDirection || scheduleDirection === "DOMESTIC") return true;
if (scheduleDirection === "IMPORT") {
return wagon.readiness === Freight.WagonReadiness.ImportReady;
}
if (scheduleDirection === "EXPORT") {
return wagon.readiness === Freight.WagonReadiness.ExportReady;
}
return true;
}
export function filterWagonsForSchedule(
wagons: Wagon[],
scheduleDirection?: string | null,
pinnedWagonIds?: Set<string>,
): Wagon[] {
return wagons.filter((wagon) => {
const isPinned = pinnedWagonIds?.has(wagon.id) ?? false;
return wagonMatchesScheduleDirection(wagon, scheduleDirection, {
allowPinned: isPinned,
});
});
}