mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
1237 lines
56 KiB
TypeScript
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;
|