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

@@ -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"
/>
);
}
}