diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 4a906eaa2..bbf2f4f95 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1201,6 +1201,15 @@ export class TrainSchedulingService { const lineEntry = lineById.get(placement.bookingContainerId); if (!lineEntry) continue; + // Durably persist the container number on the booking container line first, so it + // survives a refresh regardless of whether a wagon allocation slot can be matched + // below. booking_container is the source of truth re-read into the preview units. + if (placement.containerNumber && placement.containerNumber.trim()) { + await manager.getRepository(BookingContainer).update(placement.bookingContainerId, { + containerNumber: placement.containerNumber.trim(), + }); + } + const allocationId = allocationBySlotBooking.get( `${placement.sequenceNo}:${lineEntry.bookingId}`, ); @@ -1226,13 +1235,6 @@ export class TrainSchedulingService { bookingContainerId: placement.bookingContainerId, }); } - - // Save container number to booking_container when staff enters a new container number - if (placement.containerNumber && placement.containerNumber.trim()) { - await manager.getRepository(BookingContainer).update(placement.bookingContainerId, { - containerNumber: placement.containerNumber.trim(), - }); - } } if (containerItems.length) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 249758fb0..a450fe8c9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -53,6 +53,7 @@ export type ContainerUnitRow = { wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; + containerNumber?: string | null; }; export type ContainerPlacementInput = { @@ -121,7 +122,7 @@ export function buildContainerWagonPlan( allocations: [], })); - return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Container).map((slot) => ({ + return allocateContainersToSlots(bookings, basePlan).map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType, })); @@ -223,6 +224,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR wagonsPerUnit, containersPerWagon: perWagon, teuSlots, + containerNumber: line.containerNumber ?? null, }); } } @@ -290,6 +292,65 @@ function allocateBookingsToSlots( }); } +/** + * Allocate container bookings across wagon slots by TEU capacity. A wagon holds at most + * 2 TEU, so it carries either one 40ft container (2 TEU) or two 20ft containers (1 TEU + * each) — a 40ft is NEVER mixed onto the same wagon as a 20ft. Every physical container + * maps to a real wagon allocation, and this mirrors the frontend auto-fill packing + * exactly so a placement's sequenceNo always lands on a slot that holds an allocation + * for its booking. + * + * Weight-based packing (allocateBookingsToSlots) is wrong for containers: it collapses + * several light containers into the first wagons by tonnage and leaves later container + * units without an allocation slot, which silently drops their container items on assign. + */ +function allocateContainersToSlots( + bookings: Booking[], + basePlan: WagonPlanSlot[], +): WagonPlanSlot[] { + const slots = basePlan.map((slot) => ({ + ...slot, + assignedWeightTons: 0, + allocations: [] as WagonAllocationRecord[], + })); + if (!slots.length) return slots; + + const units = expandBookingContainerUnits(bookings); + let currentSlotIndex = 0; + let teuInCurrentSlot = 0; + + for (const unit of units) { + const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + + // Move to the next wagon once this one can't fit the container's TEU. This keeps a + // 40ft (2 TEU) alone on its wagon and never pairs it with a 20ft. + if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_SLOTS_PER_WAGON) { + currentSlotIndex += 1; + teuInCurrentSlot = 0; + } + + const slot = slots[Math.min(currentSlotIndex, slots.length - 1)]!; + + let allocation = slot.allocations.find((a) => a.bookingId === unit.bookingId); + if (!allocation) { + allocation = { + bookingId: unit.bookingId, + bookingReference: unit.bookingReference, + allocatedWeightTons: 0, + loadType: AllocationLoadType.Container, + }; + slot.allocations.push(allocation); + } + allocation.allocatedWeightTons = roundTons( + allocation.allocatedWeightTons + unit.grossWeightTons, + ); + slot.assignedWeightTons = roundTons(slot.assignedWeightTons + unit.grossWeightTons); + teuInCurrentSlot += teu; + } + + return slots; +} + export function expandContainerItems( booking: Booking, allocationId: string, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ContainerPlacementGrid.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ContainerPlacementGrid.tsx index 8ab687e92..07867d9ed 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ContainerPlacementGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ContainerPlacementGrid.tsx @@ -120,7 +120,7 @@ export function ContainerPlacementGrid({ value={progress} size="sm" radius="xl" - color={issues.length ? "yellow" : "teal"} + color={issues.length ? "yellow" : "green"} /> @@ -135,7 +135,7 @@ export function ContainerPlacementGrid({ ) : ( - + {isComplete ? "Ready" : "Pending"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx index 462c532cf..0c403e8fe 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { ArrowRight, Package } from "lucide-react"; import { Accordion, @@ -36,12 +36,15 @@ function EligibleBookingRow({ wrap="nowrap" p="sm" style={{ - border: "1px solid var(--mantine-color-gray-3)", - borderRadius: 10, - background: selected ? "var(--mantine-color-teal-0)" : undefined, + border: `1px solid ${ + selected ? "var(--mantine-color-green-3)" : "var(--mantine-color-gray-2)" + }`, + borderRadius: 12, + background: selected ? "var(--mantine-color-green-0)" : "white", + transition: "border-color 120ms ease, background 120ms ease", }} > - + @@ -202,7 +205,7 @@ export function EligibleBookingsPanel({ e.stopPropagation()}> - + {selectedInBucket.length} selected + + ) : null} + + {previewResult ? ( + + + + + + ) : null} + + ); + } + + if (key === "wagon") { + return ( + + {!displayWagonPlan.length && !previewResult ? ( + + + Run a preview from the Bookings step to generate the wagon plan. + + + ) : null} + + + {canEditBookings && (previewResult || displayWagonPlan.length) ? ( + + {!hasContainerStep ? ( + + ) : ( + + )} + + + ) : null} + + ); + } + + if (key === "container") { + return ( + + {!containerUnits.length ? ( + + + Run preview from the Bookings step to load container units for numbering. + + + ) : ( + + )} + {canEditBookings ? ( + + + + + ) : null} + + ); + } + + // finalize + return ( + + + + + + + + + Ready to depart + + Finalizing locks the plan and moves the schedule to{" "} + + SCHEDULED + + . Dispatch then begins rail movement and notifies the yard. + + + + + + {canFinalize ? ( + + ) : null} + {canDispatch ? ( + + ) : null} + {!canFinalize && !canDispatch ? ( + + No actions available for this schedule status. + + ) : null} + + + ); + }; return ( @@ -343,282 +704,208 @@ export default function TrainScheduleV2DetailPage() { Back to schedules - - - - - - - - {schedule.route?.name ?? "Train schedule"} - - {schedule.originStation?.label ?? schedule.originStation?.code} →{" "} - {schedule.destinationStation?.label ?? schedule.destinationStation?.code} - - - Departure {new Date(schedule.scheduledDepartureDate).toLocaleString()} - - - - + + + + + + + + + + + {schedule.route?.name ?? "Train schedule"} + + {schedule.trainNumber ? ( + + {schedule.trainNumber} + + ) : null} + + + + + + + + + + {schedule.status !== "DISPATCHED" ? ( - ) : null} - - - - + + + + + + - - - - Locomotive - - - {schedule.trainSet?.locomotive?.code ?? "—"} - - - - - Bookings - - - {schedule.bookings?.length ?? 0} - - - - - Wagons - - - {schedule.trainSet?.wagonCount ?? displayWagonPlan.length} ·{" "} - {schedule.trainSet?.totalWeightTons ?? 0}T - - {previewResult ? ( - + + } + > Preview {previewResult.valid ? "valid" : "has issues"} ) : null} - - - - - - - - - - - ({ - id: b.id, - reference: b.reference ?? b.id.slice(0, 8), - weightTons: b.weightTons, - }))} - eligibleItems={eligibleQuery.data?.items ?? []} - eligibleLoading={eligibleQuery.isLoading} - selectedIds={allSelectedIds} - onSelectionChange={(ids) => { - const assigned = new Set(assignedIds); - setSelectedBookingIds(ids.filter((id) => !assigned.has(id))); - }} - assignedIds={assignedIds} - freightType={freightType} - canRemove={canModifyBookings} - onRemove={handleUnassign} - /> - - {canEditBookings ? ( - - - setForceAssign(e.currentTarget.checked)} - /> - - ) : null} - - {previewResult ? ( - - - - - - ) : null} - - - - - - {!displayWagonPlan.length && !previewResult ? ( - - Run a preview from the Bookings step to generate the wagon plan. - - ) : null} - - - {canEditBookings && (previewResult || displayWagonPlan.length) ? ( - - {!hasContainerStep ? ( - - ) : ( - - )} - - - ) : null} - - - - {hasContainerStep ? ( - - - {!containerUnits.length ? ( - - - Run preview from the Bookings step to load container units for numbering. - - - ) : ( - - )} - {canEditBookings ? ( - - - - - ) : null} - - - ) : null} - - - - - - Finalize moves the schedule to SCHEDULED. Dispatch begins rail movement. - - - - {canFinalize ? ( - - ) : null} - {canDispatch ? ( - - ) : null} - - - - - + + + + + {/* Workflow header with ring progress */} + + + + + + + + Scheduling workflow + + + {completedCount} of {stepsMeta.length} steps complete · expand any + step to edit + + + + + {progressPct}% + + } + /> + + + + {stepsMeta.map((step, index) => ( + toggleStep(index)} + rightSlot={renderStepRightSlot(step.key)} + > + {renderStepBody(step.key)} + + ))} + + + {scheduleId ? ( { - if (!value) return "—"; +const splitDate = (value?: string | null) => { + if (!value) return { day: "—", time: "" }; 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); + if (Number.isNaN(date.getTime())) return { day: "—", time: "" }; + return { + day: new Intl.DateTimeFormat("en", { + month: "short", + day: "2-digit", + year: "numeric", + }).format(date), + time: new Intl.DateTimeFormat("en", { + hour: "2-digit", + minute: "2-digit", + }).format(date), + }; }; const parseError = (error: unknown, fallback: string) => { @@ -83,9 +89,28 @@ export default function TrainScheduleV2ListPage() { [routesQuery.data], ); + const allSchedules = schedulesQuery.data ?? []; + + const stats = useMemo(() => { + const base = { + total: allSchedules.length, + scheduled: 0, + dispatched: 0, + draft: 0, + weight: 0, + }; + for (const s of allSchedules) { + if (s.status === "SCHEDULED") base.scheduled += 1; + if (s.status === "DISPATCHED") base.dispatched += 1; + if (s.status === "DRAFT") base.draft += 1; + base.weight += s.totalWeightTons ?? 0; + } + return base; + }, [allSchedules]); + const filtered = useMemo(() => { const query = search.trim().toLowerCase(); - return (schedulesQuery.data ?? []).filter((s) => { + return allSchedules.filter((s) => { if (statusFilter !== "ALL" && s.status !== statusFilter) return false; if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false; if (!query) return true; @@ -103,7 +128,7 @@ export default function TrainScheduleV2ListPage() { .toLowerCase(); return haystack.includes(query); }); - }, [schedulesQuery.data, search, statusFilter, freightFilter]); + }, [allSchedules, search, statusFilter, freightFilter]); const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize)); const paged = useMemo(() => { @@ -119,19 +144,55 @@ export default function TrainScheduleV2ListPage() { id: "date", header: "Departure", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatDate(row.original.scheduleDate), + cell: ({ row }) => { + const { day, time } = splitDate(row.original.scheduleDate); + return ( + + + + + + + {day} + + + {time || "—"} + + + + ); + }, }, { id: "route", header: "Route", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.routeName ?? "—", - }, - { - id: "corridor", - header: "Corridor", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => `${row.original.origin ?? "—"} → ${row.original.destination ?? "—"}`, + cell: ({ row }) => ( + + + {row.original.routeName ?? "—"} + + + + + + ), }, { id: "freight", @@ -143,20 +204,37 @@ export default function TrainScheduleV2ListPage() { id: "loco", header: "Locomotive", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.locomotive?.code ?? "—", + cell: ({ row }) => + row.original.locomotive?.code ? ( + + + + {row.original.locomotive.code} + + + ) : ( + + — + + ), }, { id: "metrics", - header: "Bookings / Wagons", + header: "Load", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - `${row.original.bookingsCount} / ${row.original.wagonCount} · ${row.original.totalWeightTons}T`, + cell: ({ row }) => ( + + + + + + ), }, { id: "status", header: "Status", meta: { headerClassName, cellClassName }, - cell: ({ row }) => , + cell: ({ row }) => , }, { id: "actions", @@ -166,7 +244,9 @@ export default function TrainScheduleV2ListPage() { {["DRAFT", "SCHEDULED"].includes(row.original.status) ? ( + + + + + + + + + @@ -338,37 +485,15 @@ export default function TrainScheduleV2ListPage() { ) : ( {paged.map((schedule) => ( - - - - - {schedule.routeName ?? "Train schedule"} - - - - - {formatDate(schedule.scheduleDate)} - - - {schedule.origin} → {schedule.destination} - - - - - {schedule.bookingsCount} bookings · {schedule.wagonCount} wagons - - - - - + + navigate( + `/dashboard/operations/train-scheduling-v2/${schedule.id}`, + ) + } + /> ))} )} @@ -436,3 +561,127 @@ export default function TrainScheduleV2ListPage() { ); } + +function MetricChip({ + value, + label, + subtle = false, +}: { + value: string | number; + label: string; + subtle?: boolean; +}) { + return ( + + + {value} + + {label ? ( + + {label} + + ) : null} + + ); +} + +function ScheduleCard({ + schedule, + onOpen, +}: { + schedule: TrainScheduleListItem; + onOpen: () => void; +}) { + const { day, time } = splitDate(schedule.scheduleDate); + return ( + { + e.currentTarget.style.boxShadow = scheduleBrand.shadowSm; + e.currentTarget.style.transform = "translateY(-2px)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.boxShadow = ""; + e.currentTarget.style.transform = ""; + }} + > + {/* accent strip */} + + + + + + + + + + {schedule.routeName ?? "Train schedule"} + + + {day} · {time} + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 7e72c99df..208df7bd6 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -32,11 +32,13 @@ export const trainSchedulingService = { freightType?: FreightType, ): Promise => { const useUnified = !freightType || freightType === "MIXED"; + // The container/bulk endpoints already encode freight type in the path, and their + // query DTOs reject an extra `freightType` param — so only pass the station filters. const response = await client.get( useUnified ? URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS : pathsFor(freightType).ELIGIBLE_BOOKINGS, - { params: { ...filters, ...(freightType && freightType !== "MIXED" ? { freightType } : {}) } }, + { params: { ...filters } }, ); return unwrap(response.data); }, diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 5d5bd1d7c..ba28221be 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -72,6 +72,7 @@ export interface ContainerUnitRow { wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; + containerNumber?: string | null; } export interface ContainerPlacement {