Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx
2026-06-08 16:31:28 +03:00

1237 lines
56 KiB
TypeScript

import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
Accordion,
Badge,
Box,
Breadcrumbs,
Button,
Card,
Checkbox,
Divider,
Grid,
Group,
Loader,
Modal,
MultiSelect,
Notification,
Progress,
ScrollArea,
SegmentedControl,
Select,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { Calendar, CheckCircle2, MapPin, RefreshCw, Send, Train, TrainTrack } from "lucide-react";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useRoutes } from "@/hooks/useRoutes";
import { useWagons } from "@/hooks/useWagons";
import { bookingsService } from "@/services/bookings.service";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { Wagon } from "@/services/wagon.service";
import type { BookingDetail } from "@/types/booking";
import type {
AssignmentType,
EligibleContainerBooking,
LocomotiveRecord,
TradeDirection,
TrainSchedulePreviewResponse,
} from "@/types/trainScheduling";
const formatDate = (value?: string | null) => {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("en", {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const toIso = (value: string) => new Date(value).toISOString();
const TRAIN_LIMITS = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxContainerWagons: 53,
maxBulkWagons: 37,
} as const;
const assignmentLabels: Record<AssignmentType, string> = {
CONTAINER: "Wagon for Container",
BULK: "Wagon for Bulk",
};
const ETHIOPIA_NAMES = new Set(["ethiopia", "et"]);
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
const violations = error.response?.data?.violations ?? error.response?.data?.message?.violations;
if (Array.isArray(violations)) return violations.join(", ");
}
if (error instanceof Error) return error.message;
return fallback;
};
const statusColor = (status?: string | null) => {
switch (status) {
case "AVAILABLE":
case "READY":
case "PUBLISHED":
case "PAID":
return "green";
case "IMPORT_READY":
case "EXPORT_READY":
case "IN_TRANSIT":
return "blue";
case "ARRIVED":
case "COMPLETED":
return "teal";
case "CANCELLED":
case "UNAVAILABLE":
return "red";
case "DRAFT":
case "WAGON_ASSIGNED":
case "INVOICED":
return "yellow";
default:
return "gray";
}
};
const routeDirection = (originCountry?: string, destinationCountry?: string): TradeDirection => {
const origin = (originCountry ?? "").trim().toLowerCase();
const destination = (destinationCountry ?? "").trim().toLowerCase();
if (!ETHIOPIA_NAMES.has(origin) && ETHIOPIA_NAMES.has(destination)) return "IMPORT";
if (ETHIOPIA_NAMES.has(origin) && !ETHIOPIA_NAMES.has(destination)) return "EXPORT";
return "DOMESTIC";
};
const expectedWagonStatus = (direction: string) => {
if (direction === "IMPORT") return "IMPORT_READY";
if (direction === "EXPORT" || direction === "DOMESTIC") return "EXPORT_READY";
return "AVAILABLE";
};
const wagonLabel = (wagon: Wagon) =>
`${wagon.wagonNumber} - ${wagon.maxPayloadWeight ?? 0}T / ${wagon.status}`;
const wagonSupportsAssignment = (wagon: Wagon, assignmentType: AssignmentType) =>
(wagon.wagonType?.supportedLoadTypes ?? [])
.map((loadType) => loadType.trim().toUpperCase())
.includes(assignmentType);
const bookingYardLabel = (yard?: { label?: string; name?: string; code?: string } | null) =>
yard?.label ?? yard?.name ?? yard?.code ?? "-";
const isDjiboutiYard = (yard?: { label?: string; name?: string; code?: string; country?: string } | null) => {
const value = [yard?.country, yard?.code, yard?.label, yard?.name]
.filter(Boolean)
.join(" ")
.toLowerCase();
return value.includes("djibouti") || value.includes("djoubti") || value.includes("djib");
};
const bookingMatchesDirection = (booking: BookingDetail, direction: TradeDirection) => {
if (direction === "IMPORT") return isDjiboutiYard(booking.originYard);
if (direction === "EXPORT") return isDjiboutiYard(booking.destinationYard);
return true;
};
const bookingQuantity = (booking: BookingDetail) =>
booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0;
const toEligibleBooking = (booking: BookingDetail): EligibleContainerBooking => ({
id: booking.id,
reference: booking.reference,
customer: booking.company?.name ?? booking.company?.companyName ?? "Unknown customer",
containerType:
booking.freightType === "BULK"
? booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo"
: booking.bookingContainers
?.map((container) => container.containerType?.label ?? container.containerType?.code ?? "Container")
.join(", ") || "Container",
quantity: bookingQuantity(booking),
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
origin: bookingYardLabel(booking.originYard),
destination: bookingYardLabel(booking.destinationYard),
preferredDepartureDate: booking.scheduledDate,
status: booking.status,
});
function MetricTile({ label, value }: { label: string; value: string | number }) {
return (
<Card withBorder radius="md" p="md">
<Text size="xs" tt="uppercase" c="dimmed" fw={700}>
{label}
</Text>
<Text size="sm" fw={600} mt={8}>
{value}
</Text>
</Card>
);
}
function LocomotiveCard({
locomotive,
selected,
onSelect,
}: {
locomotive: LocomotiveRecord;
selected: boolean;
onSelect: () => void;
}) {
return (
<Card withBorder radius="md" p="md" bg={selected ? "blue.0" : undefined}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Box>
<Text fw={700}>{locomotive.code}</Text>
<Text size="sm" c="dimmed">
{locomotive.name ?? locomotive.locomotiveType ?? "Locomotive"}
</Text>
</Box>
<Badge color={statusColor(locomotive.status)} variant="light">
{locomotive.status}
</Badge>
</Group>
<SimpleGrid cols={2}>
<MetricTile label="Pull" value={`${locomotive.maxPullWeightTons} T`} />
<MetricTile label="Length" value={`${locomotive.maxTrainLengthMeters} m`} />
</SimpleGrid>
<Button variant={selected ? "filled" : "light"} onClick={onSelect}>
{selected ? "Selected" : "Select locomotive"}
</Button>
</Stack>
</Card>
);
}
const TrainsPage = () => {
const qc = useQueryClient();
const [routeId, setRouteId] = useState("");
const [departureDate, setDepartureDate] = useState("");
const [arrivalDate, setArrivalDate] = useState("");
const [assignmentType, setAssignmentType] = useState<AssignmentType>("CONTAINER");
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState("");
const [selectedWagonIds, setSelectedWagonIds] = useState<string[]>([]);
const [wagonSearch, setWagonSearch] = useState("");
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [locomotiveSearch, setLocomotiveSearch] = useState("");
const [locomotiveStatusFilter, setLocomotiveStatusFilter] = useState("ALL");
const [scheduleSearch, setScheduleSearch] = useState("");
const [scheduleStatusFilter, setScheduleStatusFilter] = useState("ALL");
const [detailId, setDetailId] = useState<string | null>(null);
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
const [formMessage, setFormMessage] = useState<{ color: string; title: string; message: string } | null>(null);
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
const routesQuery = useRoutes();
const wagonsQuery = useWagons();
const locomotivesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
const stationsQuery = useQuery({
queryKey: [
...QUERY_KEYS.TRAIN_SCHEDULING.stations(),
selectedLocomotiveId,
],
queryFn: () => trainSchedulingService.getStations(),
enabled: Boolean(selectedLocomotiveId),
});
const schedulesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
queryFn: () => trainSchedulingService.listSchedules(),
});
const detailQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ""),
queryFn: () => trainSchedulingService.getScheduleById(detailId!),
enabled: Boolean(detailId),
});
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((route) => route.isActive),
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((route) => route.id === routeId) ?? null;
const selectedLocomotive = (locomotivesQuery.data ?? []).find(
(locomotive) => locomotive.id === selectedLocomotiveId,
);
const maxWagons =
assignmentType === "CONTAINER" ? TRAIN_LIMITS.maxContainerWagons : TRAIN_LIMITS.maxBulkWagons;
const selectedRouteOrigin = selectedRoute?.originYard;
const selectedRouteDestination = selectedRoute?.destinationYard;
const selectedRouteDirection = routeDirection(
selectedRouteOrigin?.country,
selectedRouteDestination?.country,
);
const requiredWagonStatus = expectedWagonStatus(selectedRouteDirection);
const stationNameById = useMemo(() => {
return new Map((stationsQuery.data ?? []).map((station) => [station.id, station]));
}, [stationsQuery.data]);
const stationLabel = (yardId?: string, fallback?: { label?: string; code?: string } | null) => {
const station = yardId ? stationNameById.get(yardId) : undefined;
return station
? `${station.name}${station.code ? ` (${station.code})` : ""}`
: fallback?.label ?? fallback?.code ?? "-";
};
const bookingsApiFilter = useMemo(
() => ({
page: 1,
pageSize: 1000,
sortBy: "scheduledDate",
sortOrder: "ASC" as const,
}),
[],
);
const bookingsApiQuery = useQuery({
queryKey: [
...QUERY_KEYS.BOOKINGS.list(bookingsApiFilter),
"train-assignment",
routeId,
],
queryFn: () => bookingsService.list(bookingsApiFilter),
enabled: Boolean(selectedLocomotive && selectedRoute),
});
const eligibleBookings = useMemo<EligibleContainerBooking[]>(() => {
return (bookingsApiQuery.data?.items ?? [])
.filter((booking) => {
const matchesFreightType = booking.freightType === assignmentType;
const matchesCorridorDirection = bookingMatchesDirection(booking, selectedRouteDirection);
return matchesFreightType && matchesCorridorDirection;
})
.map(toEligibleBooking);
}, [assignmentType, bookingsApiQuery.data?.items, selectedRouteDirection]);
const filteredLocomotives = useMemo(() => {
const query = locomotiveSearch.trim().toLowerCase();
return (locomotivesQuery.data ?? []).filter((locomotive) => {
const matchesStatus = locomotiveStatusFilter === "ALL" || locomotive.status === locomotiveStatusFilter;
if (!matchesStatus) return false;
if (!query) return true;
return [locomotive.code, locomotive.name ?? "", locomotive.status, locomotive.locomotiveType ?? ""]
.join(" ")
.toLowerCase()
.includes(query);
});
}, [locomotiveSearch, locomotiveStatusFilter, locomotivesQuery.data]);
const availableWagons = useMemo(() => {
const query = wagonSearch.trim().toLowerCase();
return (wagonsQuery.data ?? []).filter((wagon) => {
const isUnassigned = !wagon.trainId;
const isAtRouteOrigin = Boolean(selectedRoute?.originYardId) && wagon.currentLocationYardId === selectedRoute?.originYardId;
const isReadyForRoute = wagon.status === requiredWagonStatus;
const supportsAssignment = wagonSupportsAssignment(wagon, assignmentType);
const matchesQuery = query
? [
wagon.wagonNumber,
wagon.status,
wagon.wagonTypeId,
wagon.wagonType?.code ?? "",
wagon.wagonType?.name ?? "",
wagon.currentLocationYard?.label ?? "",
wagon.currentLocationYard?.code ?? "",
wagon.currentLocationYard?.country ?? "",
wagon.notes ?? "",
]
.join(" ")
.toLowerCase()
.includes(query)
: true;
return isUnassigned && isAtRouteOrigin && isReadyForRoute && supportsAssignment && matchesQuery;
});
}, [assignmentType, requiredWagonStatus, selectedRoute?.originYardId, wagonSearch, wagonsQuery.data]);
const routeReadyWagonCounts = useMemo(() => {
return (wagonsQuery.data ?? []).reduce(
(counts, wagon) => {
const isRouteReady =
!wagon.trainId &&
Boolean(selectedRoute?.originYardId) &&
wagon.currentLocationYardId === selectedRoute?.originYardId &&
wagon.status === requiredWagonStatus;
if (!isRouteReady) return counts;
if (wagonSupportsAssignment(wagon, "CONTAINER")) counts.container += 1;
if (wagonSupportsAssignment(wagon, "BULK")) counts.bulk += 1;
return counts;
},
{ container: 0, bulk: 0 },
);
}, [requiredWagonStatus, selectedRoute?.originYardId, wagonsQuery.data]);
const filteredSchedules = useMemo(() => {
const query = scheduleSearch.trim().toLowerCase();
return (schedulesQuery.data ?? []).filter((schedule) => {
const matchesStatus = scheduleStatusFilter === "ALL" || schedule.status === scheduleStatusFilter;
if (!matchesStatus) return false;
if (!query) return true;
return [
schedule.id,
schedule.routeName ?? "",
schedule.origin ?? "",
schedule.destination ?? "",
schedule.locomotive?.code ?? "",
schedule.status,
]
.join(" ")
.toLowerCase()
.includes(query);
});
}, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
const bookingOptions = eligibleBookings.map((booking) => ({
value: booking.id,
label: `${booking.reference} - ${booking.customer} (${booking.weightTons} T / ${booking.status})`,
}));
const selectedBookings = eligibleBookings.filter((booking) =>
selectedBookingIds.includes(booking.id),
);
const selectedWeightTons = selectedBookings.reduce(
(total, booking) => total + Number(booking.weightTons || 0),
0,
);
const previewWagonLimitExceeded = preview ? preview.summary.wagonsNeeded > maxWagons : false;
const selectedWagonLimitExceeded = selectedWagonIds.length > maxWagons;
const selectedWagonsShort =
preview ? selectedWagonIds.length < preview.summary.wagonsNeeded : selectedWagonIds.length === 0;
const previewWeightExceeded = preview
? preview.summary.totalWeightTons > TRAIN_LIMITS.maxWeightTons
: false;
const previewLengthExceeded = preview
? preview.summary.totalLengthMeters > TRAIN_LIMITS.maxLengthMeters
: false;
const canGenerateSchedule =
Boolean(routeId) &&
Boolean(departureDate) &&
Boolean(arrivalDate) &&
Boolean(selectedLocomotiveId) &&
selectedBookingIds.length > 0 &&
selectedWagonIds.length > 0 &&
!selectedWagonLimitExceeded &&
(!preview || !selectedWagonsShort);
const resetPreview = () => setPreview(null);
const previewMutation = useMutation({
mutationFn: () => {
if (!selectedRoute || !departureDate || selectedBookingIds.length === 0) {
throw new Error("Select route, departure date, and at least one eligible booking");
}
return trainSchedulingService.preview({
bookingIds: selectedBookingIds,
scheduleDate: toIso(departureDate),
originStationId: selectedRoute.originYardId,
destinationStationId: selectedRoute.destinationYardId,
assignmentType,
});
},
onSuccess: (data) => {
setPreview(data);
setFormMessage({
color: data.valid ? "green" : "red",
title: data.valid ? "Assignment preview ready" : "Assignment needs attention",
message: data.valid
? "Wagon count, weight, and length validations passed."
: data.violations.join(", "),
});
},
onError: (error) => {
setFormMessage({ color: "red", title: "Preview failed", message: parseError(error, "Failed to preview assignment") });
},
});
const createMutation = useMutation({
mutationFn: () => {
if (!routeId || !departureDate || !arrivalDate || !selectedLocomotiveId || selectedBookingIds.length === 0 || selectedWagonIds.length === 0) {
throw new Error("Select locomotive, route, departure, arrival, wagons, and bookings");
}
if (selectedWagonLimitExceeded) {
throw new Error(`Select no more than ${maxWagons} wagons for this locomotive`);
}
if (preview && selectedWagonIds.length < preview.summary.wagonsNeeded) {
throw new Error(`Select at least ${preview.summary.wagonsNeeded} wagons for this assignment`);
}
return trainSchedulingService.createSchedule({
routeId,
scheduleDate: toIso(departureDate),
arrivalDate: toIso(arrivalDate),
locomotiveId: selectedLocomotiveId,
assignmentType,
bookingIds: selectedBookingIds,
wagonIds: selectedWagonIds,
});
},
onSuccess: (data) => {
setFormMessage({
color: "green",
title: "Schedule generated",
message: "The schedule was created and added to Created schedules.",
});
setInvoiceModalOpen(true);
setRouteId("");
setDepartureDate("");
setArrivalDate("");
setSelectedLocomotiveId("");
setSelectedWagonIds([]);
setSelectedBookingIds([]);
setPreview(null);
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void schedulesQuery.refetch();
void bookingsApiQuery.refetch();
void wagonsQuery.refetch();
void locomotivesQuery.refetch();
setDetailId(data.id);
},
onError: (error) => {
setFormMessage({ color: "red", title: "Schedule failed", message: parseError(error, "Failed to generate schedule") });
},
});
const cancelMutation = useMutation({
mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id),
onSuccess: (data) => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
setDetailId(data.id);
},
onError: (error) => {
setFormMessage({ color: "red", title: "Cancel failed", message: parseError(error, "Failed to cancel schedule") });
},
});
const publishMutation = useMutation({
mutationFn: (id: string) => trainSchedulingService.publishSchedule(id),
onSuccess: (data) => {
setFormMessage({
color: "green",
title: "Schedule published",
message: "The schedule is published and customers can be notified.",
});
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
setDetailId(data.id);
},
onError: (error) => {
setFormMessage({ color: "red", title: "Publish failed", message: parseError(error, "Failed to publish schedule") });
},
});
const detail = detailQuery.data;
const weightProgress = preview
? Math.min(100, (preview.summary.totalWeightTons / TRAIN_LIMITS.maxWeightTons) * 100)
: 0;
const lengthProgress = preview
? Math.min(100, (preview.summary.totalLengthMeters / TRAIN_LIMITS.maxLengthMeters) * 100)
: 0;
return (
<Box p="lg">
<Stack gap="lg">
<Breadcrumbs>
<Text size="sm" c="dimmed">Operations</Text>
<Text size="sm" fw={600}>Locomotive scheduling</Text>
</Breadcrumbs>
{formMessage ? (
<Notification color={formMessage.color} title={formMessage.title} onClose={() => setFormMessage(null)}>
{formMessage.message}
</Notification>
) : null}
<Card withBorder radius="md">
<Stack gap={0}>
<Group justify="space-between" align="center" p="lg">
<Group align="flex-start" gap="md">
<ThemeIcon size={52} radius="md">
<TrainTrack size={28} />
</ThemeIcon>
<Box>
<Title order={2}>Locomotive Scheduling</Title>
<Text size="sm" c="dimmed" mt={4}>
Select a locomotive, route, wagon assignment type, and available bookings before generating a scheduled train.
</Text>
</Box>
</Group>
<Button
variant="default"
leftSection={<RefreshCw size={16} />}
onClick={() => {
void routesQuery.refetch();
void schedulesQuery.refetch();
void locomotivesQuery.refetch();
void wagonsQuery.refetch();
void bookingsApiQuery.refetch();
}}
>
Refresh
</Button>
</Group>
<Divider />
<Grid p="lg" gutter="lg">
<Grid.Col span={{ base: 12, xl: 7 }}>
<Stack>
<Card withBorder radius="md">
<Stack>
<Group justify="space-between">
<Box>
<Title order={3}>Select locomotive</Title>
<Text size="sm" c="dimmed">Use the table to choose the locomotive that will pull this scheduled train.</Text>
</Box>
<Group gap="xs">
{selectedLocomotive ? (
<Badge color="blue" variant="light">
{selectedLocomotive.code} selected
</Badge>
) : null}
<Badge variant="light">{filteredLocomotives.length} locomotives</Badge>
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
placeholder="Search locomotive"
value={locomotiveSearch}
onChange={(event) => setLocomotiveSearch(event.currentTarget.value)}
/>
<Select
value={locomotiveStatusFilter}
data={[
"ALL",
"AVAILABLE",
"IMPORT_READY",
"EXPORT_READY",
"UNAVAILABLE",
"ASSIGNED",
"MAINTENANCE",
"OUT_OF_SERVICE",
]}
onChange={(value) => setLocomotiveStatusFilter(value ?? "ALL")}
/>
</SimpleGrid>
<Tabs defaultValue="table">
<Tabs.List>
<Tabs.Tab value="table">Table view</Tabs.Tab>
<Tabs.Tab value="cards">Card view</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="table" pt="md">
<ScrollArea>
<Table striped highlightOnHover verticalSpacing="sm" miw={820}>
<Table.Thead>
<Table.Tr>
<Table.Th w={56}>Select</Table.Th>
<Table.Th>Locomotive</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Pull</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredLocomotives.map((locomotive) => {
const isSelected = locomotive.id === selectedLocomotiveId;
return (
<Table.Tr
key={locomotive.id}
bg={isSelected ? "blue.0" : undefined}
style={{ cursor: "pointer" }}
onClick={() => {
setSelectedLocomotiveId(locomotive.id);
setRouteId("");
setSelectedWagonIds([]);
setSelectedBookingIds([]);
resetPreview();
}}
>
<Table.Td>
<Checkbox checked={isSelected} readOnly aria-label={`Select ${locomotive.code}`} />
</Table.Td>
<Table.Td>
<Text fw={700}>{locomotive.code}</Text>
<Text size="xs" c="dimmed">{locomotive.name ?? "Locomotive"}</Text>
</Table.Td>
<Table.Td>{locomotive.locomotiveType ?? "-"}</Table.Td>
<Table.Td>{locomotive.maxPullWeightTons} T</Table.Td>
<Table.Td>{locomotive.maxTrainLengthMeters} m</Table.Td>
<Table.Td>
<Badge color={statusColor(locomotive.status)} variant="light">{locomotive.status}</Badge>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea>
</Tabs.Panel>
<Tabs.Panel value="cards" pt="md">
{locomotivesQuery.isLoading ? <Loader /> : null}
<SimpleGrid cols={{ base: 1, md: 2 }}>
{filteredLocomotives.map((locomotive) => (
<LocomotiveCard
key={locomotive.id}
locomotive={locomotive}
selected={locomotive.id === selectedLocomotiveId}
onSelect={() => {
setSelectedLocomotiveId(locomotive.id);
setRouteId("");
setSelectedWagonIds([]);
setSelectedBookingIds([]);
resetPreview();
}}
/>
))}
</SimpleGrid>
</Tabs.Panel>
</Tabs>
</Stack>
</Card>
<Card withBorder radius="md">
<Stack>
<Title order={3}>Created schedules</Title>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<TextInput
placeholder="Search by route, locomotive, or status"
value={scheduleSearch}
onChange={(event) => setScheduleSearch(event.currentTarget.value)}
/>
<Select
value={scheduleStatusFilter}
data={["ALL", "DRAFT", "READY", "PUBLISHED", "DEPARTED", "IN_TRANSIT", "ARRIVED", "COMPLETED", "CANCELLED"]}
onChange={(value) => setScheduleStatusFilter(value ?? "ALL")}
/>
</SimpleGrid>
<ScrollArea>
<Table striped highlightOnHover verticalSpacing="sm" miw={980}>
<Table.Thead>
<Table.Tr>
<Table.Th>Schedule</Table.Th>
<Table.Th>Departure</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Locomotive</Table.Th>
<Table.Th>Bookings</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredSchedules.map((schedule) => (
<Table.Tr key={schedule.id}>
<Table.Td><Text size="xs" ff="monospace">{schedule.id}</Text></Table.Td>
<Table.Td>{formatDate(schedule.scheduleDate)}</Table.Td>
<Table.Td>{schedule.routeName ?? `${schedule.origin ?? "-"} to ${schedule.destination ?? "-"}`}</Table.Td>
<Table.Td>{schedule.locomotive?.code ?? "-"}</Table.Td>
<Table.Td>{schedule.bookingsCount}</Table.Td>
<Table.Td>{schedule.wagonCount}</Table.Td>
<Table.Td><Badge color={statusColor(schedule.status)} variant="light">{schedule.status}</Badge></Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Button variant="default" size="xs" onClick={() => setDetailId(schedule.id)}>View</Button>
{schedule.status === "READY" ? (
<Button size="xs" leftSection={<Send size={14} />} onClick={() => publishMutation.mutate(schedule.id)}>
Publish
</Button>
) : null}
{schedule.status !== "CANCELLED" ? (
<Button color="red" variant="light" size="xs" onClick={() => cancelMutation.mutate(schedule.id)}>
Cancel
</Button>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={8}>
<Text ta="center" c="dimmed" py="xl">No schedules matched the current filters.</Text>
</Table.Td>
</Table.Tr>
) : null}
</Table.Tbody>
</Table>
</ScrollArea>
</Stack>
</Card>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, xl: 5 }}>
<Stack>
<Card withBorder radius="md">
<Stack>
<Group gap="xs">
<Calendar size={16} />
<Title order={3}>Scheduled train builder</Title>
</Group>
<SegmentedControl
value={assignmentType}
onChange={(value) => {
setAssignmentType(value as AssignmentType);
setSelectedWagonIds([]);
setSelectedBookingIds([]);
resetPreview();
}}
data={[
{ value: "CONTAINER", label: "Wagon for Container" },
{ value: "BULK", label: "Wagon for Bulk" },
]}
/>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<MetricTile label="Max wagons" value={`${maxWagons} ${assignmentType.toLowerCase()}`} />
<MetricTile label="Max train length" value={`${TRAIN_LIMITS.maxLengthMeters} m`} />
<MetricTile label="Max train weight" value={`${TRAIN_LIMITS.maxWeightTons} T`} />
</SimpleGrid>
<Select
label="Selected locomotive"
placeholder="Select a locomotive from the table"
data={(locomotivesQuery.data ?? []).map((locomotive) => ({
value: locomotive.id,
label: `${locomotive.code} - ${locomotive.maxPullWeightTons}T / ${locomotive.maxTrainLengthMeters}m`,
}))}
value={selectedLocomotiveId || null}
searchable
onChange={(value) => {
setSelectedLocomotiveId(value ?? "");
setRouteId("");
setSelectedWagonIds([]);
setSelectedBookingIds([]);
resetPreview();
}}
/>
<Select
label="Route"
placeholder={selectedLocomotiveId ? "Select active route" : "Select locomotive first"}
data={activeRoutes.map((route) => ({
value: route.id,
label: `${route.name} - ${stationLabel(route.originYardId, route.originYard)} to ${stationLabel(
route.destinationYardId,
route.destinationYard,
)}`,
}))}
value={routeId || null}
searchable
disabled={!selectedLocomotiveId}
rightSection={stationsQuery.isFetching ? <Loader size={16} /> : undefined}
onChange={(value) => {
setRouteId(value ?? "");
setSelectedWagonIds([]);
setSelectedBookingIds([]);
resetPreview();
}}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label="Departure date/time"
type="datetime-local"
value={departureDate}
onChange={(event) => {
setDepartureDate(event.currentTarget.value);
resetPreview();
}}
/>
<TextInput
label="Arrival date/time"
type="datetime-local"
value={arrivalDate}
onChange={(event) => setArrivalDate(event.currentTarget.value)}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Card withBorder radius="md" p="md">
<Group gap="xs" mb={6}>
<MapPin size={14} />
<Text size="xs" tt="uppercase" c="dimmed" fw={700}>Origin station</Text>
</Group>
<Text size="sm" fw={600}>
{stationLabel(selectedRoute?.originYardId, selectedRouteOrigin)}
</Text>
</Card>
<Card withBorder radius="md" p="md">
<Group gap="xs" mb={6}>
<MapPin size={14} />
<Text size="xs" tt="uppercase" c="dimmed" fw={700}>Destination station</Text>
</Group>
<Text size="sm" fw={600}>
{stationLabel(selectedRoute?.destinationYardId, selectedRouteDestination)}
</Text>
</Card>
</SimpleGrid>
<Card withBorder radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Box>
<Title order={4}>Assign wagons to train</Title>
<Text size="sm" c="dimmed">
{selectedRoute
? `${selectedRouteDirection} route: showing unassigned ${requiredWagonStatus} wagons at ${stationLabel(selectedRoute.originYardId, selectedRouteOrigin)}.`
: "Select a route to load route-ready wagons."}
</Text>
</Box>
<Group gap="xs">
<Badge color={selectedWagonLimitExceeded ? "red" : "blue"} variant="light">
{selectedWagonIds.length} / {maxWagons} selected
</Badge>
{preview ? (
<Badge color={selectedWagonsShort ? "red" : "green"} variant="light">
{preview.summary.wagonsNeeded} needed
</Badge>
) : null}
</Group>
</Group>
<TextInput
placeholder="Search wagons"
value={wagonSearch}
disabled={!selectedRoute}
onChange={(event) => setWagonSearch(event.currentTarget.value)}
/>
<Group gap="xs">
<Badge color="blue" variant={assignmentType === "CONTAINER" ? "filled" : "light"}>
{routeReadyWagonCounts.container} Wagon for Container
</Badge>
<Badge color="teal" variant={assignmentType === "BULK" ? "filled" : "light"}>
{routeReadyWagonCounts.bulk} Wagon for Bulk
</Badge>
<Badge color="gray" variant="light">
Showing {assignmentLabels[assignmentType]}
</Badge>
</Group>
<ScrollArea h={260}>
<Table striped highlightOnHover verticalSpacing="sm" miw={720}>
<Table.Thead>
<Table.Tr>
<Table.Th w={56}>Select</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Payload</Table.Th>
<Table.Th>Sequence</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{availableWagons.map((wagon) => {
const isSelected = selectedWagonIds.includes(wagon.id);
const cannotSelectMore = !isSelected && selectedWagonIds.length >= maxWagons;
return (
<Table.Tr
key={wagon.id}
bg={isSelected ? "teal.0" : undefined}
style={{ cursor: cannotSelectMore ? "not-allowed" : "pointer" }}
onClick={() => {
if (cannotSelectMore) return;
setSelectedWagonIds((current) =>
current.includes(wagon.id)
? current.filter((id) => id !== wagon.id)
: [...current, wagon.id],
);
}}
>
<Table.Td>
<Checkbox
checked={isSelected}
disabled={cannotSelectMore}
readOnly
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
</Table.Td>
<Table.Td>
<Text fw={700}>{wagon.wagonNumber}</Text>
<Text size="xs" c="dimmed">
{wagon.wagonType
? `${wagon.wagonType.code} - ${wagon.wagonType.name}`
: wagon.wagonTypeId}
</Text>
</Table.Td>
<Table.Td>
<Badge color={statusColor(wagon.status)} variant="light">{wagon.status}</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">
{wagon.currentLocationYard?.label ?? wagon.currentLocationYard?.code ?? "-"}
</Text>
<Text size="xs" c="dimmed">
{wagon.currentLocationYard?.country ?? ""}
</Text>
</Table.Td>
<Table.Td>{wagon.maxPayloadWeight ?? 0} T</Table.Td>
<Table.Td>{isSelected ? selectedWagonIds.indexOf(wagon.id) + 1 : "-"}</Table.Td>
</Table.Tr>
);
})}
{!wagonsQuery.isLoading && availableWagons.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text ta="center" c="dimmed" py="lg">
No wagons at the selected route origin matched this readiness selection.
</Text>
</Table.Td>
</Table.Tr>
) : null}
</Table.Tbody>
</Table>
</ScrollArea>
{selectedWagonsShort ? (
<Notification color="yellow" title="More wagons needed" withCloseButton={false}>
{preview
? `Select ${preview.summary.wagonsNeeded - selectedWagonIds.length} more wagon(s) to match the previewed allocation.`
: "Select wagons before generating the scheduled train."}
</Notification>
) : null}
</Stack>
</Card>
<MultiSelect
label="Eligible bookings"
placeholder={
selectedLocomotive && selectedRoute
? "Assign bookings to the scheduled train wagons"
: "Select locomotive and route first"
}
data={bookingOptions}
value={selectedBookingIds}
searchable
clearable
disabled={!selectedLocomotive || !selectedRoute}
rightSection={bookingsApiQuery.isFetching ? <Loader size={16} /> : undefined}
onChange={(value) => {
setSelectedBookingIds(value);
resetPreview();
}}
/>
<Group gap="xs">
<Badge variant="light">{eligibleBookings.length} available bookings</Badge>
<Badge color="gray" variant="light">{bookingsApiQuery.data?.total ?? 0} from bookings API</Badge>
<Badge color={selectedRouteDirection === "IMPORT" ? "blue" : selectedRouteDirection === "EXPORT" ? "teal" : "gray"} variant="light">
{selectedRouteDirection === "IMPORT"
? "Import: Djibouti origin"
: selectedRouteDirection === "EXPORT"
? "Export: Djibouti destination"
: "Domestic route"}
</Badge>
<Badge color="teal" variant="light">{selectedBookingIds.length} selected</Badge>
<Badge color={selectedWeightTons > TRAIN_LIMITS.maxWeightTons ? "red" : "gray"} variant="light">
{selectedWeightTons} / {TRAIN_LIMITS.maxWeightTons} T before preview
</Badge>
</Group>
<Group grow>
<Button variant="light" loading={previewMutation.isPending} onClick={() => previewMutation.mutate()}>
Preview allocation
</Button>
<Button
leftSection={<CheckCircle2 size={16} />}
loading={createMutation.isPending}
disabled={!canGenerateSchedule}
onClick={() => createMutation.mutate()}
>
Generate schedule
</Button>
</Group>
</Stack>
</Card>
<Card withBorder radius="md">
<Stack>
<Title order={3}>Assignment summary</Title>
{preview ? (
<>
<SimpleGrid cols={2}>
<MetricTile label="Bookings" value={preview.summary.totalBookings} />
<MetricTile label="Wagon type" value={assignmentLabels[assignmentType]} />
<MetricTile label="Wagons" value={`${preview.summary.wagonsNeeded} / ${maxWagons}`} />
<MetricTile label="Weight" value={`${preview.summary.totalWeightTons} / ${TRAIN_LIMITS.maxWeightTons} T`} />
<MetricTile label="Length" value={`${preview.summary.totalLengthMeters} / ${TRAIN_LIMITS.maxLengthMeters} m`} />
</SimpleGrid>
<Box>
<Text size="xs" c="dimmed" fw={700}>Weight utilization</Text>
<Progress value={weightProgress} mt={6} />
</Box>
<Box>
<Text size="xs" c="dimmed" fw={700}>Length utilization</Text>
<Progress value={lengthProgress} mt={6} color="teal" />
</Box>
{preview.violations.length > 0 ? (
<Notification color="red" title="Validation issues" withCloseButton={false}>
{preview.violations.join(", ")}
</Notification>
) : null}
{previewWagonLimitExceeded || previewWeightExceeded || previewLengthExceeded ? (
<Notification color="red" title="Train limit exceeded" withCloseButton={false}>
Keep this {assignmentLabels[assignmentType]} assignment within {maxWagons} wagons, {TRAIN_LIMITS.maxWeightTons} T, and {TRAIN_LIMITS.maxLengthMeters} m.
</Notification>
) : null}
<Accordion variant="contained">
<Accordion.Item value="wagons">
<Accordion.Control>{assignmentLabels[assignmentType]} allocation preview</Accordion.Control>
<Accordion.Panel>
<Stack>
{preview.wagonPlan.map((wagon) => (
<Card key={wagon.sequenceNo} withBorder radius="md" p="sm">
<Group justify="space-between">
<Text fw={600}>{assignmentLabels[assignmentType]} {wagon.sequenceNo}</Text>
<Badge variant="light">{wagon.allocations.length} bookings</Badge>
</Group>
<Text size="sm" c="dimmed">
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
</Text>
<Text size="xs" c="dimmed" mt={4}>
{wagon.allocations.map((allocation) => allocation.bookingReference).join(", ") || "No allocation"}
</Text>
</Card>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
) : (
<Stack gap="sm">
<Group gap="xs">
<Train size={16} />
<Text size="sm" fw={600}>{assignmentLabels[assignmentType]}</Text>
</Group>
<Text size="sm" c="dimmed">
Run preview after selecting bookings to assign wagons and validate the {maxWagons} wagon, {TRAIN_LIMITS.maxWeightTons} T, and {TRAIN_LIMITS.maxLengthMeters} m train rules.
</Text>
</Stack>
)}
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Stack>
</Card>
</Stack>
<Modal opened={invoiceModalOpen} onClose={() => setInvoiceModalOpen(false)} title="Invoice preview" centered>
<Stack>
<Notification color="green" title="Invoice workflow started" withCloseButton={false}>
The backend moved assigned bookings to INVOICED after wagon allocation. Customers can pay after invoice generation, and booking status becomes PAID only after payment verification.
</Notification>
<Button onClick={() => setInvoiceModalOpen(false)}>Done</Button>
</Stack>
</Modal>
<Modal opened={Boolean(detailId)} onClose={() => setDetailId(null)} title="Schedule detail" size="80rem" centered>
{detail ? (
<Stack>
<SimpleGrid cols={{ base: 1, md: 2, xl: 5 }}>
<MetricTile label="Schedule" value={detail.id} />
<MetricTile label="Departure" value={formatDate(detail.scheduledDepartureDate)} />
<MetricTile label="Arrival" value={formatDate(detail.scheduledArrivalDate)} />
<MetricTile label="Route" value={detail.route?.name ?? "-"} />
<Card withBorder radius="md" p="md">
<Text size="xs" tt="uppercase" c="dimmed" fw={700}>Status</Text>
<Badge color={statusColor(detail.status)} variant="light" mt={8}>{detail.status}</Badge>
</Card>
</SimpleGrid>
<Card withBorder radius="md">
<Title order={3}>Locomotive</Title>
<Text size="sm" c="dimmed" mt={8}>
{detail.trainSet?.locomotive
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T / ${detail.trainSet.locomotive.maxTrainLengthMeters ?? 0}m)`
: "No locomotive attached"}
</Text>
</Card>
<Card withBorder radius="md">
<Title order={3}>Assigned wagons</Title>
{(detail.trainSet?.wagons?.length ?? 0) === 0 ? (
<Text size="sm" c="dimmed" mt="sm">No wagons allocated yet.</Text>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} mt="md">
{(detail.trainSet?.wagons ?? []).map((wagon) => (
<Card key={wagon.id} withBorder radius="md" p="md">
<Text fw={600}>
Wagon {wagon.sequenceNo} - {wagon.physicalWagon?.wagonNumber ?? wagon.wagonType?.code ?? "NW5"}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
</Text>
{wagon.physicalWagon ? (
<Badge color={statusColor(wagon.physicalWagon.status)} variant="light" mt={6}>
{wagon.physicalWagon.status}
</Badge>
) : null}
<Text size="xs" c="dimmed" mt={4}>
{wagon.allocations.map((allocation) => allocation.bookingReference).join(", ") || "No allocations"}
</Text>
</Card>
))}
</SimpleGrid>
)}
</Card>
<Card withBorder radius="md">
<Title order={3}>Assigned bookings</Title>
<ScrollArea mt="md">
<Table striped highlightOnHover verticalSpacing="sm" miw={560}>
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{detail.bookings.map((booking) => (
<Table.Tr key={booking.id}>
<Table.Td>{booking.reference ?? booking.id}</Table.Td>
<Table.Td>{booking.customer ?? "-"}</Table.Td>
<Table.Td>{booking.weightTons} T</Table.Td>
<Table.Td><Badge color={statusColor(booking.status)} variant="light">{booking.status ?? "-"}</Badge></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Card>
</Stack>
) : (
<Group justify="center" p="xl">
<Loader />
</Group>
)}
</Modal>
</Box>
);
};
export default TrainsPage;