diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index c9a526f6b..233ab81ac 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -93,12 +93,14 @@ function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) ))} @@ -108,7 +110,7 @@ function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) function Coupler() { return ( - + @@ -142,62 +145,109 @@ function LocomotiveCar({ - {/* cab windows */} + {/* roofline */} - - + /> + {/* roof vents */} + + {[0, 1, 2].map((i) => ( + + ))} + + {/* cab windows */} + + + {/* headlight */} + {/* hazard stripe on the nose */} + - + {code} - + {name ?? "Locomotive"} {maxPullWeightTons ? ( - + - + {maxPullWeightTons}T pull ) : null} - + HEAD @@ -205,6 +255,15 @@ function LocomotiveCar({ ); } +const CONTAINER_GRADIENTS = [ + "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", + "linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))", +]; +const CONTAINER_BORDERS = [ + "var(--mantine-color-cyan-8)", + "var(--mantine-color-blue-8)", +]; + function WagonCar({ wagon }: { wagon: NormalizedWagon }) { const utilization = wagon.capacityTons > 0 @@ -219,7 +278,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : "" }${ wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : "" - }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}`; + }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`; // container blocks: one per container number (cap visual at 2 = TEU per wagon) const blocks = wagon.containerNumbers.slice(0, 2); @@ -230,13 +289,15 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { {wagon.isEmpty ? ( - + EMPTY ) : ( {wagon.isBulk ? : } - + {wagon.isBulk ? "BULK" : "CONT"} @@ -271,7 +332,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { {/* body */} - + {wagon.isEmpty ? ( @@ -296,11 +357,13 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { position: "absolute", inset: 0, width: `${utilization}%`, - background: "linear-gradient(90deg, var(--mantine-color-orange-5), var(--mantine-color-orange-3))", + background: + "linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))", + boxShadow: "inset 0 1px 0 rgba(255,255,255,0.35)", }} /> - + {wagon.assignedWeightTons}/{wagon.capacityTons}T @@ -312,17 +375,30 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { style={{ flex: 1, minWidth: 0, - height: 26, + height: 28, borderRadius: 5, - background: "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", - border: "1px solid var(--mantine-color-cyan-8)", + background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length], + border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`, + boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.15)", display: "flex", + flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "0 3px", }} > - + {/* corrugation lines */} + + {cn} @@ -331,6 +407,22 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { )} + {/* utilization hairline */} + {!wagon.isEmpty && !wagon.isBulk ? ( + + = 100 + ? "var(--mantine-color-red-5)" + : `var(--mantine-color-${accent}-5)`, + }} + /> + + ) : null} + {/* footer */} - - {wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"} - + + + {wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"} + + {!wagon.isEmpty ? ( + + {wagon.assignedWeightTons}T + + ) : null} + @@ -350,6 +449,56 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { ); } +/** Railway track: two rails over evenly-spaced sleepers. */ +function TrackBed() { + return ( + + {/* sleepers */} + + {/* rails */} + + + + ); +} + export function TrainCompositionDiagram({ locomotive, wagons, @@ -404,8 +553,7 @@ export function TrainCompositionDiagram({ withBorder style={{ borderColor: "var(--mantine-color-gray-2)", - background: - "linear-gradient(180deg, var(--mantine-color-gray-0) 0%, white 40%)", + background: "white", }} > @@ -417,17 +565,20 @@ export function TrainCompositionDiagram({ display: "flex", alignItems: "center", justifyContent: "center", - width: 38, - height: 38, - borderRadius: 10, + width: 40, + height: 40, + borderRadius: 11, background: freightBrand.gradient, + boxShadow: freightBrand.shadowSm, color: "white", }} > - + - Train composition + + Train composition + {trainNumber ? `${trainNumber} · ` : ""} {stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty @@ -436,35 +587,58 @@ export function TrainCompositionDiagram({ - - - - + + + + {stats.totalWeight}T + + + of {stats.totalCapacity}T capacity + + + + + + + {/* Locomotive pull gauge */} {stats.pullUtil != null ? ( - - - Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T - - 95 ? "red.7" : "green.7"}> + + + + + Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T + + + 95 ? "red.7" : "green.7"}> {stats.pullUtil}% 95} + animated={stats.pullUtil > 95} color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"} /> @@ -480,20 +654,8 @@ export function TrainCompositionDiagram({ const turnSide: "left" | "right" = rowIndex % 2 === 0 ? "right" : "left"; return ( - - {/* rail under the row */} - + + = [ + { + key: "allocated", + label: "Allocated", + color: "green", + hint: "Assigned to wagons on the train", + }, + { + key: "selectedForBatch", + label: "Selected", + color: "orange", + hint: "Picked by batch — customer notified to pay", + }, + { + key: "ready", + label: "Ready", + color: "teal", + hint: "Contract signed — waiting for batch pick", + }, + { + key: "waiting", + label: "Waiting", + color: "blue", + hint: "Paid — waiting for a slot", + }, + { + key: "pendingContract", + label: "Pending contract", + color: "gray", + hint: "Contract not signed yet", + }, + { + key: "expired", + label: "Expired", + color: "red", + hint: "Payment deadline missed", + }, +]; + +export function totalBookingCount(counts: BatchCounts): number { + return PIPELINE_STAGES.reduce((sum, stage) => sum + counts[stage.key], 0); +} + +/** + * Stacked booking-pipeline bar: one colored segment per batch state, with an + * optional dot legend underneath. Reads as a single glanceable funnel. + */ +export function BookingPipeline({ + counts, + size = 10, + showLegend = true, +}: { + counts: BatchCounts; + size?: number; + showLegend?: boolean; +}) { + const total = totalBookingCount(counts); + const stages = PIPELINE_STAGES.filter((s) => counts[s.key] > 0); + + if (!total) { + return ( + + + + No bookings yet + + + ); + } + + return ( + + + {stages.map((stage) => ( + + + + ))} + + {showLegend ? ( + + {stages.map((stage) => ( + + + + + {counts[stage.key]} + {" "} + {stage.label.toLowerCase()} + + + ))} + + ) : null} + + ); +} + +const WINDOW_META: Record = { + OPEN: { color: "green", label: "Window open", pulse: true }, + FULL: { color: "orange", label: "Full", pulse: false }, + CLOSED: { color: "gray", label: "Closed", pulse: false }, +}; + +/** + * Booking-window status pill with a status dot (pulsing while OPEN), styled for + * a clean white surface. + */ +export function WindowStatusPill({ status }: { status: string }) { + const meta = WINDOW_META[status] ?? { color: "gray", label: status, pulse: false }; + return ( + + + + {meta.label} + + + ); +} + +/** + * Small neutral info chip used in the page headers (date, loco, status, …). + * Clean light surface that reads well on white. + */ +export function HeroChip({ + icon, + children, +}: { + icon?: ReactNode; + children: ReactNode; +}) { + return ( + + {icon} + + {children} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index 224c1c5ba..f5dd76e33 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -1,30 +1,43 @@ +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { - Badge, + Alert, Box, Button, Container, Group, - Loader, Paper, - Progress, + RingProgress, SimpleGrid, + Skeleton, Stack, Text, ThemeIcon, Title, - Tooltip, } from "@mantine/core"; import { + AlertTriangle, ArrowRight, + CalendarDays, + Inbox, LayoutGrid, + Package, RefreshCw, Ruler, Train, + TrainFront, Weight, } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { + BookingPipeline, + HeroChip, + totalBookingCount, + WindowStatusPill, +} from "@/components/trainScheduling/batchVisuals"; +import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals"; +import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; import type { BatchBoardSchedule } from "@/types/trainScheduling"; @@ -34,187 +47,235 @@ const fmtTons = (n: number) => const fmtMeters = (n: number) => `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`; +const fmtScheduleDate = (iso: string | null) => + iso + ? new Intl.DateTimeFormat("en-GB", { + weekday: "short", + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Africa/Addis_Ababa", + }).format(new Date(iso)) + " EAT" + : "No date"; + +function ringColor(pct: number) { + if (pct >= 100) return "red"; + if (pct >= 85) return "orange"; + return "green"; +} + +/** Capacity ring that keeps the explicit allocated/max numbers underneath. */ +function CapacityRing({ + pct, + label, + current, + max, +}: { + pct: number; + label: string; + current: string; + max: string; +}) { + const clamped = Math.min(100, Math.max(0, pct)); + const color = ringColor(pct); + return ( + + + + {Math.round(pct)}% + + + {label} + + + } + /> + + + {current} + + + of {max} + + + + ); +} + function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const navigate = useNavigate(); const { capacity, counts, locomotive } = schedule; + const lengthPct = capacity.maxLengthMeters && capacity.maxLengthMeters > 0 ? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100 - : 0; + : null; const weightPct = capacity.maxWeightTons && capacity.maxWeightTons > 0 ? (capacity.usedWeightTons / capacity.maxWeightTons) * 100 - : 0; + : null; - const windowColor = - schedule.bookingWindowStatus === "OPEN" - ? "green" - : schedule.bookingWindowStatus === "FULL" - ? "orange" - : "gray"; - - const totalBookings = - counts.allocated + - counts.selectedForBatch + - counts.ready + - counts.waiting + - counts.pendingContract + - counts.expired; + const totalBookings = totalBookingCount(counts); return ( navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`) } > + {/* thin brand accent line */} - + + + {/* header */} - - + + - + {schedule.trainNumber ?? schedule.routeName ?? "Schedule"} - - {schedule.origin ?? "—"} → {schedule.destination ?? "—"} - - - {schedule.scheduleDate - ? new Date(schedule.scheduleDate).toLocaleString() - : "No date"} + + Freight schedule · {schedule.status} - - - {schedule.bookingWindowStatus} - - - {schedule.status} - - + - {locomotive ? ( - - Loco {locomotive.code} · max {fmtTons(locomotive.maxPullWeightTons)} ·{" "} - {locomotive.maxTrainLengthMeters} m - - ) : ( - - No locomotive — won't allocate - - )} + - - - - Allocated wagons - - - {capacity.allocatedWagons} - - - - {capacity.maxLengthMeters ? ( - - - - - - Train length - - - - {fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)} - - - = 100 ? "orange" : "blue"} - radius="xl" - size="sm" - /> - - ) : null} - {capacity.maxWeightTons ? ( - - - - - - Weight - - - - {fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)} - - - = 100 ? "red" : "teal"} - radius="xl" - size="sm" - /> - - ) : null} - - - - - {counts.allocated} allocated - - - - - {counts.selectedForBatch} selected - - - - - {counts.ready} ready - - - - - {counts.waiting} waiting - - - {counts.expired ? ( - - {counts.expired} expired - + + }> + {fmtScheduleDate(schedule.scheduleDate)} + + {locomotive ? ( + }> + {locomotive.code} · {fmtTons(locomotive.maxPullWeightTons)} + ) : null} - - {totalBookings - ? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows` - : "No bookings yet · click to open"} - + {!locomotive ? ( + } + py={6} + styles={{ message: { fontSize: 12 } }} + > + No locomotive assigned — wagon allocation cannot run. + + ) : null} + {/* capacity: weight + length rings + wagons (numbers preserved) */} + + + {weightPct != null ? ( + + ) : null} + + + + + + + {capacity.allocatedWagons} + + + Wagons + + + allocated + + + + {lengthPct != null ? ( + + ) : null} + + + + {/* booking pipeline */} + + + + + + Booking pipeline + + + + {totalBookings} booking{totalBookings === 1 ? "" : "s"} + + + + + + + {/* CTA */} + + + + ); +} + +function CardSkeleton() { + return ( + + + + + + + + + + + + + ); @@ -231,35 +312,58 @@ export default function BatchBoardPage() { const { data, isLoading, isFetching, refetch } = useBatchBoard(); const schedules = data ?? []; + const summary = useMemo(() => { + const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length; + const totalBookings = schedules.reduce((sum, s) => sum + totalBookingCount(s.counts), 0); + const totalWagons = schedules.reduce((sum, s) => sum + s.capacity.allocatedWagons, 0); + return { openWindows, totalBookings, totalWagons }; + }, [schedules]); + return ( - + + {/* Hero — clean white */} - + - - + + - - Allocation · Live - - + <Group gap={8}> + <Text + size="xs" + fw={700} + c="green.7" + tt="uppercase" + style={{ letterSpacing: 1.2 }} + > + Allocation · Live + </Text> + <Box + w={7} + h={7} + className="bb-pulse-dot" + style={{ borderRadius: 999, background: FREIGHT_BRAND }} + /> + </Group> + <Title order={2} fw={800} style={{ color: "#0f172a", letterSpacing: 0.2 }}> Batch board - - Active schedules — click a card to see EAT 3-hour batch windows, bookings, and - wagon allocation status. + + Active schedules — open a card to see EAT 3-hour batch windows, bookings, + and live wagon allocation. @@ -273,20 +377,64 @@ export default function BatchBoardPage() { Refresh + + + + + + {isLoading ? ( - - - + + + + + ) : schedules.length === 0 ? ( - - - No active schedules to show. - + + + + + + + No active schedules + + + Schedules with an open booking window appear here as cards. Create or activate + a schedule to get started. + + ) : ( - + {schedules.map((s) => ( ))} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 2097bca9b..520d6cb08 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import type { ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, @@ -10,7 +11,6 @@ import { Group, Loader, Paper, - Progress, SimpleGrid, Stack, Table, @@ -22,20 +22,33 @@ import { import { AlertTriangle, ArrowLeft, + Boxes, + CalendarDays, CheckCircle2, Clock, + FileSignature, Hourglass, Layers, + Package, PlayCircle, RefreshCw, - Train, - Weight, Ruler, + Train, + TrainFront, + Weight, XCircle, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; +import { + BookingPipeline, + HeroChip, + totalBookingCount, + WindowStatusPill, +} from "@/components/trainScheduling/batchVisuals"; +import { RouteCorridor, scheduleBrand } from "@/components/trainScheduling/scheduleVisuals"; import { useBatchBoardDetail, useRunAllocation, @@ -89,6 +102,15 @@ const fmtDateTime = (iso: string | null) => }).format(new Date(iso)) : "—"; +const initials = (name: string) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0]) + .join("") + .toUpperCase() || "?"; + function StateBadge({ state }: { state: BatchBoardBookingState }) { const meta = STATE_META[state]; const Icon = meta.icon; @@ -132,73 +154,166 @@ function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) { ); } + const th = (label: string) => ( + + {label} + + ); + return ( - - - - Reference - Customer - Contract signed - Selected for batch - Capacity - Batch state - Wagon allocation - - - - {bookings.map((b) => ( - - - - - {b.reference} - - {b.isGovernment ? ( - - Gov - - ) : null} - - - - - {b.company} - - - - {fmtDateTime(b.fullyExecutedAt)} EAT - - - {b.selectedForBatchAt ? ( - <> - {fmtDateTime(b.selectedForBatchAt)} EAT - {b.paymentDeadline ? ( - - Pay by {fmtDateTime(b.paymentDeadline)} EAT - - ) : null} - - ) : ( - - — - - )} - - - - {b.wagons}w · {fmtTons(b.weightTons)} - - - - - - - - + +
+ + + {th("Reference")} + {th("Customer")} + {th("Contract signed")} + {th("Selected for batch")} + {th("Capacity")} + {th("Batch state")} + {th("Wagon allocation")} - ))} - -
+ + + {bookings.map((b) => ( + + + + + {b.reference} + + {b.isGovernment ? ( + + Gov + + ) : null} + + + + + + + {initials(b.company)} + + + + {b.company} + + + + + + {fmtDateTime(b.fullyExecutedAt)} EAT + + + + {b.selectedForBatchAt ? ( + <> + + {fmtDateTime(b.selectedForBatchAt)} EAT + + {b.paymentDeadline ? ( + + Pay by {fmtDateTime(b.paymentDeadline)} EAT + + ) : null} + + ) : ( + + — + + )} + + + + + {b.wagons}w + + + {fmtTons(b.weightTons)} + + + + + + + + + + + ))} + + +
+ ); +} + +function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { + const chips: Array<{ value: number; color: string; label: string }> = [ + { value: counts.allocated, color: "green", label: "allocated" }, + { value: counts.selectedForBatch, color: "orange", label: "selected" }, + { value: counts.ready, color: "teal", label: "ready" }, + { value: counts.waiting, color: "blue", label: "waiting" }, + { value: counts.expired, color: "red", label: "expired" }, + ].filter((c) => c.value > 0); + + return ( + + {chips.map((c) => ( + + + + + {c.value} + + + + ))} + ); } @@ -211,19 +326,51 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { return ( - - - {window.label} - + + + + + + + + {window.label} + + + {total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"} + + + {hasIssues ? ( - + } + > Issues ) : null} - - {total} booking{total === 1 ? "" : "s"} - + @@ -234,6 +381,83 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { ); } +/** Light stat card with an optional capacity bar — keeps the explicit numbers. */ +function StatCard({ + icon: Icon, + label, + value, + sub, + pct, +}: { + icon: LucideIcon; + label: string; + value: ReactNode; + sub?: string; + pct?: number | null; +}) { + const barColor = + pct == null + ? "green" + : pct >= 100 + ? "red" + : pct >= 85 + ? "orange" + : "green"; + return ( + + + + + + + + {label} + + + {value} + + {pct != null ? ( + + + + ) : null} + {sub ? ( + + {sub} + + ) : null} + + + + ); +} + export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); @@ -287,7 +511,7 @@ export default function BatchScheduleDetailPage() { if (isLoading || !data) { return ( - + @@ -298,14 +522,16 @@ export default function BatchScheduleDetailPage() { const lengthPct = data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0 ? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100 - : 0; + : null; const weightPct = data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0 ? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100 - : 0; + : null; + + const totalBookings = totalBookingCount(data.counts); return ( - + - - - - - - - - + {/* Hero — clean white */} + + + + + + + + -
- - {data.trainNumber ?? data.routeName ?? "Schedule"} - - - {data.origin ?? "—"} → {data.destination ?? "—"} ·{" "} - {data.scheduleDate - ? new Date(data.scheduleDate).toLocaleString() - : "No date"} - -
+ + + + {data.trainNumber ?? data.routeName ?? "Schedule"} + + + {data.status} + + + + }> + {data.scheduleDate + ? new Intl.DateTimeFormat("en-GB", { + weekday: "short", + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Africa/Addis_Ababa", + }).format(new Date(data.scheduleDate)) + " EAT" + : "No date"} + + {data.locomotive ? ( + }> + Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "} + {data.locomotive.maxTrainLengthMeters} m + + ) : null} + +
- - - {data.bookingWindowStatus} - - - {data.status} - - -
-
- - - - - - -
- - {data.locomotive ? ( - - Loco {data.locomotive.code} · max {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "} - {data.locomotive.maxTrainLengthMeters} m - - ) : ( - }> - No locomotive assigned — wagon allocation cannot run. - - )} - - - - - - Allocated wagons - - - {data.capacity.allocatedWagons} - - - {data.capacity.maxLengthMeters ? ( - - - - - - Train length - - - - {fmtMeters(data.capacity.allocatedLengthMeters)}/ - {fmtMeters(data.capacity.maxLengthMeters)} - - - = 100 ? "orange" : "blue"} - radius="xl" - /> - - ) : null} - {data.capacity.maxWeightTons ? ( - - - - - - Weight - - - - {fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)} - - - = 100 ? "red" : "teal"} - radius="xl" - /> - - ) : null} - - - - {data.counts.allocated} allocated - - - {data.counts.selectedForBatch} selected - - - {data.counts.ready} ready - - - {data.counts.waiting} waiting - - - {data.counts.pendingContract} pending contract - - {data.counts.expired ? ( - - {data.counts.expired} expired - + + + + + + + + {!data.locomotive ? ( + }> + No locomotive assigned — wagon allocation cannot run. + ) : null} + + + + + + + + +
+ + {/* Booking pipeline */} + + + + + + + Booking pipeline + + + {totalBookings} booking{totalBookings === 1 ? "" : "s"} + + {data.allocationViolations.length ? ( - } title="Allocation constraints"> + } + title="Allocation constraints" + > {data.allocationViolations.map((v) => ( @@ -483,26 +720,80 @@ export default function BatchScheduleDetailPage() { ) : null} - - - Batch windows (EAT) - - - Bookings are grouped by contract signing time (fullyExecutedAt). Expand a - window to see bookings and wagon allocation issues. - + {/* Batch windows */} + + + + + + + Batch windows (EAT) + + Bookings grouped by contract signing time in 3-hour windows — expand one to + see bookings and wagon allocation issues. + + + - + {data.windows.map((window) => ( ))} {data.pendingContract.bookings.length ? ( - - - Pending contract - + + + + + + + + Pending contract + + + Contract not signed yet — not in any window + + + {data.pendingContract.bookings.length} booking {data.pendingContract.bookings.length === 1 ? "" : "s"} @@ -517,11 +808,9 @@ export default function BatchScheduleDetailPage() { + {/* Train composition */} {hasAssignedWagons && scheduleDetailQuery.data ? ( - - - Train composition - + - +
) : null} ); -} \ No newline at end of file +}