diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index b4520a43c..3b9fddf1f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -83,6 +83,11 @@ export class BookingJourneyService { loadedByUserId: userId ?? null, } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + // Keep the schedule↔booking link's tracking flag in sync — the dispatch + // readiness warnings and workspace badges read loading_status, not loadedAt. + await manager + .getRepository(TrainScheduleBooking) + .update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' }); // The facility handed the cargo over — raise its GRN. No-ops for yards // without a facility (import/export terminals), which keep their own flow. await this.facilityHandling.recordHandling(manager, { @@ -236,7 +241,10 @@ export class BookingJourneyService { return { scheduleId, scheduleStatus: schedule.status, - trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId), + // No checkpoint yet ⇒ the train is still at its origin, even just after + // dispatch — assertTrainAtYard allows origin loading in that state, so + // the UI position must agree or origin Load buttons grey out wrongly. + trainAtYardId: latest?.yardId ?? schedule.originStationId, yards: [...byYard.values()], }; } 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 f4d5eefdb..0648ac12c 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 @@ -1977,6 +1977,16 @@ export class TrainSchedulingService { manager, ); + // Unassign only runs pre-dispatch, so an IN_TRANSIT status here is stale + // (e.g. auto-loaded by an earlier dispatch that was rolled back). Left as + // is, the booking becomes invisible: the eligible pool only admits PAID, + // so it can never be re-added to any train. Revert it to PAID. + if (booking?.status === 'IN_TRANSIT' && !booking.arrivedAt) { + await manager + .getRepository(Booking) + .update(bookingId, { status: 'PAID', loadedAt: null } as never); + } + // Recompute the train-set composition from whatever survives this removal. // The removed booking's allocations were already deleted above, so any slot // left with zero allocations was ridden only by this booking — release it @@ -6407,7 +6417,60 @@ export class TrainSchedulingService { action: 'BOOKING_REMOVED', yardLabel: null, })); - return [...wagonRows, ...bookingRows].sort( + // Per-booking journey events (load at boarding yard / unload at alighting + // yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a + // multi-stop train's disjoint legs (a→b loads then unloads at b while a→c + // rides through) each show as their own row. Append-only: these columns are + // only ever set once per booking, never cleared, so rows never disappear. + const journeyRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT b.id, + b.reference AS "subject", + COALESCE(oy.label, oy.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + b.loaded_at AS "occurredAt" + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id + WHERE b.loaded_at IS NOT NULL + AND b.deleted_at IS NULL + ORDER BY b.loaded_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_LOADED', + note: null, + })); + const unloadRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT b.id, + b.reference AS "subject", + COALESCE(dy.label, dy.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + b.arrived_at AS "occurredAt" + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id + WHERE b.arrived_at IS NOT NULL + AND b.deleted_at IS NULL + ORDER BY b.arrived_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_UNLOADED', + note: null, + })); + return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort( (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), ); } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx index 49029615f..729c7a5d4 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx @@ -13,7 +13,9 @@ import { History, MapPin, Minus, + PackageCheck, PackageMinus, + PackageOpen, Plus, User, } from "lucide-react"; @@ -29,6 +31,8 @@ const ACTION_META: Record< REMOVE: { label: "Wagon trimmed", color: "red", icon: Minus }, SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight }, BOOKING_REMOVED: { label: "Booking removed", color: "orange", icon: PackageMinus }, + BOOKING_LOADED: { label: "Booking loaded", color: "edr-green", icon: PackageCheck }, + BOOKING_UNLOADED: { label: "Booking unloaded", color: "blue", icon: PackageOpen }, }; /** @@ -57,8 +61,9 @@ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: strin Change history - Wagons coupled, trimmed or switched — and bookings removed — after - this train was scheduled, newest first. + Wagons coupled, trimmed or switched, bookings loaded/unloaded per + yard, and bookings removed — after this train was scheduled, + newest first. diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 423df6a7c..44cd3053c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -22,10 +22,13 @@ import { ArrowRight, CheckCircle2, Inbox, + Landmark, + MapPin, PackageCheck, - PackageX, + PackageOpen, // Repeat, // used by the hidden Move (reassign) button Train, + TrainFront, Weight, X, } from "lucide-react"; @@ -39,6 +42,7 @@ import type { EligibleContainerBooking, FreightType, TrainScheduleDetail, + YardWorkBookingRow, } from "@/types/trainScheduling"; interface ScheduleWorkspacePanelProps { @@ -151,6 +155,9 @@ export function ScheduleWorkspacePanel({ const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status); const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status); + // Loading follows the train: it keeps working AFTER dispatch, per yard, as + // checkpoints are logged — only add/remove is closed once the train rolls. + const canWork = ["DRAFT", "SCHEDULED", "DISPATCHED"].includes(schedule.status); // Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not // yet linked to any schedule (same filter the auto-batch uses). @@ -180,14 +187,83 @@ export function ScheduleWorkspacePanel({ const onTrain = schedule.bookings ?? []; + // ── Corridor position: which yard the train currently stands at ─────────── + // The journey worklist knows the train's latest checkpoint AND per-booking + // load/unload eligibility — the same server rules that gate the mutations. + const yardWorkQuery = useQuery( + api.trainScheduling.yardWork.queryOptions({ + input: { scheduleId: schedule.id }, + refetchInterval: 60_000, + }), + ); + const trainAtYardId = yardWorkQuery.data?.trainAtYardId ?? null; + const journeyById = useMemo(() => { + const map = new Map(); + for (const yard of yardWorkQuery.data?.yards ?? []) { + for (const row of [...yard.toLoad, ...yard.toUnload]) map.set(row.id, row); + } + return map; + }, [yardWorkQuery.data]); + + // Ordered corridor (origin → stops → destination). Falls back to the two + // endpoints when the schedule has no stops recorded. + const stations = useMemo(() => { + const stops = schedule.stops ?? []; + if (stops.length) return stops; + return [ + { yardId: schedule.originStation?.id ?? "origin", label: schedule.originStation?.label ?? "Origin" }, + { + yardId: schedule.destinationStation?.id ?? "destination", + label: schedule.destinationStation?.label ?? "Destination", + }, + ]; + }, [schedule.stops, schedule.originStation, schedule.destinationStation]); + const stationIdx = useMemo( + () => new Map(stations.map((s, i) => [s.yardId, i])), + [stations], + ); + const trainIdx = trainAtYardId != null ? (stationIdx.get(trainAtYardId) ?? null) : null; + const trainAtLabel = + trainIdx != null ? stations[trainIdx]?.label : null; + + // On-train bookings grouped by BOARDING yard, in corridor order. A booking + // whose origin is off this corridor (through cargo on legacy data) groups + // under the train's own origin. + const corridorGroups = useMemo(() => { + const groups = new Map(); + for (const b of onTrain) { + const yardId = + b.originYardId && stationIdx.has(b.originYardId) + ? b.originYardId + : (stations[0]?.yardId ?? "origin"); + let group = groups.get(yardId); + if (!group) { + group = { + yardId, + label: + stations[stationIdx.get(yardId) ?? 0]?.label ?? b.origin ?? "Origin", + rows: [], + }; + groups.set(yardId, group); + } + group.rows.push(b); + } + return [...groups.values()].sort( + (a, b) => (stationIdx.get(a.yardId) ?? 0) - (stationIdx.get(b.yardId) ?? 0), + ); + }, [onTrain, stationIdx, stations]); + // ── Mutations (reuse the existing endpoints) ─────────────────────────────── const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); const assignUnassigned = useMutation( api.trainScheduling.assignUnassignedBooking.mutationOptions(), ); const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); - const setLoading = useMutation( - api.trainScheduling.setLoadingStatus.mutationOptions(), + const loadJourney = useMutation( + api.trainScheduling.loadScheduleBooking.mutationOptions(), + ); + const unloadJourney = useMutation( + api.trainScheduling.unloadScheduleBooking.mutationOptions(), ); const moveSchedule = useMutation( api.trainScheduling.moveBookingSchedule.mutationOptions(), @@ -296,26 +372,42 @@ export function ScheduleWorkspacePanel({ ); }; - const toggleLoaded = ( - bookingId: string, - ref: string, - next: "LOADED" | "UNLOADED", - ) => { - setLoading - .mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next }) + // Journey load/unload — the server checks the train's recorded position, so + // a stale UI can never load cargo at the wrong yard. + const doLoad = (bookingId: string, ref: string) => { + loadJourney + .mutateAsync({ scheduleId: schedule.id, bookingId }) .then(() => { - toast({ - title: - next === "LOADED" - ? `${ref} marked loaded` - : `${ref} marked unloaded`, - }); + toast({ title: `${ref} loaded onto the train` }); onChanged(); + void yardWorkQuery.refetch(); }) .catch((error) => toast({ - title: "Could not update loading status", - description: apiErrorMessage(error, "Please try again."), + title: "Could not load cargo", + description: apiErrorMessage(error, "Train may not be at the boarding yard."), + variant: "destructive", + }), + ); + }; + + const doUnload = (bookingId: string, ref: string) => { + unloadJourney + .mutateAsync({ scheduleId: schedule.id, bookingId }) + .then((result) => { + toast({ + title: + result.status === "COMPLETED" + ? `${ref} unloaded — booking completed` + : `${ref} unloaded — booking arrived`, + }); + onChanged(); + void yardWorkQuery.refetch(); + }) + .catch((error) => + toast({ + title: "Could not unload cargo", + description: apiErrorMessage(error, "Train may not be at the destination yard."), variant: "destructive", }), ); @@ -382,7 +474,8 @@ export function ScheduleWorkspacePanel({
Allocation workspace - Manually add paid, unassigned bookings, remove, or reassign them + Add or remove bookings, then load each one when the train is at + its boarding yard
@@ -458,7 +551,10 @@ export function ScheduleWorkspacePanel({ {locked ? ( This train is {schedule.status.toLowerCase()} — bookings can no longer be - changed. + added or removed. + {schedule.status === "DISPATCHED" + ? " Loading continues per yard as checkpoints are logged on the track page." + : ""} ) : null} @@ -485,6 +581,10 @@ export function ScheduleWorkspacePanel({ weightTons={b.weightTons} status={b.status} waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"} + government={b.isGovernment} + leg={ + b.origin && b.destination ? `${b.origin} → ${b.destination}` : null + } right={ canManage ? ( @@ -525,114 +625,174 @@ export function ScheduleWorkspacePanel({ ))} - {/* On train */} + {/* On train — grouped by boarding yard, walked in corridor order. + Load is only offered where the train actually stands; the journey + endpoints re-validate the position server-side. */} - {onTrain.map((b) => ( - - {b.wagonAssigned ? ( - - + + ) : null} + {showUnload ? ( + + + + ) : null} + {canManage && !riding && !done ? ( + journey?.isGovernment ? null : ( + + + ) - } - loading={setLoading.isPending} - onClick={() => - toggleLoaded( - b.id, - b.reference ?? b.id.slice(0, 8), - (b.loadingStatus ?? "UNLOADED") === "LOADED" - ? "UNLOADED" - : "LOADED", - ) - } - > - {(b.loadingStatus ?? "UNLOADED") === "LOADED" - ? "Unload" - : "Load"} - - - ) : null} - {/* Reassign-to-another-train — hidden for now. - - - - */} - - - - - ) : null - } - /> - ))} + ) : null} + + } + /> + ); + })} + + ); + })} @@ -807,6 +967,7 @@ function BookingCard({ loadingStatus, waitingForWagon, intercity, + government, leg, right, }: { @@ -819,6 +980,8 @@ function BookingCard({ waitingForWagon?: boolean; /** DOMESTIC ride-along riding only part of this train's corridor. */ intercity?: boolean; + /** Government booking — remove is blocked, only switch. */ + government?: boolean; /** "Origin → Destination" when the booking rides a sub-corridor leg. */ leg?: string | null; right?: React.ReactNode; @@ -856,6 +1019,22 @@ function BookingCard({ ) : null} + {government ? ( + + } + > + Government + + + ) : null} {waitingForWagon ? ( { - const message = (error as { response?: { data?: { message?: string | string[] } } }) - ?.response?.data?.message; - if (Array.isArray(message)) return message.join("; "); - return message || (error as Error)?.message || fallback; -}; - -const fmtDate = (iso: string) => { - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); -}; - -const DIRECTION_COLORS: Record = { - IMPORT: "blue", - EXPORT: "teal", - DOMESTIC: "violet", -}; - -/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */ -const DIRECTION_LABELS: Record = Freight.TRADE_DIRECTION_LABELS; - -function DirectionChip({ direction }: { direction: string }) { - return ( - - {DIRECTION_LABELS[direction] ?? direction} - - ); -} - -function BookingCell({ row }: { row: YardWorkBookingRow }) { - return ( - - - {row.reference ?? row.id.slice(0, 8)} - - {row.isGovernment && ( - - GOV - - )} - - ); -} - -function WorkTable({ - rows, - side, - trainHere, - onLoad, - onUnload, - pendingBookingId, -}: { - rows: YardWorkBookingRow[]; - side: "load" | "unload"; - trainHere: boolean; - onLoad: (bookingId: string) => void; - onUnload: (bookingId: string) => void; - pendingBookingId: string | null; -}) { - if (rows.length === 0) { - return ( - - {side === "load" ? "No bookings board here." : "No bookings alight here."} - - ); - } - return ( - - - - - Booking - Customer - Direction - Status - {side === "load" ? "Loaded" : "Arrived"} - - - - - {rows.map((row) => { - const timestamp = side === "load" ? row.loadedAt : row.arrivedAt; - const canAct = side === "load" ? row.canLoad : row.canUnload; - return ( - - - - - - {row.customer} - - - - - - - - - {timestamp ? ( - - {fmtDate(timestamp)} - - ) : ( - - — - - )} - - - - {side === "load" ? ( - - - - ) : ( - - - - )} - - - - ); - })} - -
-
- ); -} - -/** - * Per-yard load/unload worklist for one schedule — every trade direction. Each - * booking boards at its origin yard and alights at its destination yard; the - * operator confirms both while the train's last recorded checkpoint is at that - * yard (the server validates the position). Unloading stamps the booking's own - * arrival — ARRIVED for import/export, COMPLETED for intercity. - */ -export function YardWorkPanel({ scheduleId }: { scheduleId: string }) { - const { toast } = useToast(); - const queryClient = useQueryClient(); - - const yardWorkQuery = useQuery( - api.trainScheduling.yardWork.queryOptions({ - input: { scheduleId }, - refetchInterval: 60_000, - }), - ); - - // Loading/unloading changes booking status on the schedule detail and the - // intercity panel too — refresh all three so no surface shows a stale state. - const invalidate = () => { - void queryClient.invalidateQueries({ - queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }), - }); - void queryClient.invalidateQueries({ - queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }), - }); - void queryClient.invalidateQueries({ - queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }), - }); - }; - - const load = useMutation( - api.trainScheduling.loadScheduleBooking.mutationOptions({ - onSuccess: () => { - void invalidate(); - toast({ title: "Cargo loaded" }); - }, - onError: (err) => - toast({ - title: "Load failed", - description: parseError(err, "Could not confirm loading"), - variant: "destructive", - }), - }), - ); - - const unload = useMutation( - api.trainScheduling.unloadScheduleBooking.mutationOptions({ - onSuccess: (result) => { - void invalidate(); - toast({ - title: - result.status === "COMPLETED" - ? "Cargo unloaded — booking completed" - : "Cargo unloaded — booking arrived", - }); - }, - onError: (err) => - toast({ - title: "Unload failed", - description: parseError(err, "Could not confirm unloading"), - variant: "destructive", - }), - }), - ); - - const data = yardWorkQuery.data; - const yards: YardWorkYard[] = data?.yards ?? []; - const trainAtYardId = data?.trainAtYardId ?? null; - const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null; - const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null; - - return ( - - - - - Yard load / unload - - - {yardWorkQuery.isLoading ? ( - - - - Loading yard worklists… - - - ) : yardWorkQuery.isError ? ( - }> - {parseError(yardWorkQuery.error, "Could not load the yard worklist")} - - ) : yards.length === 0 ? ( - - No bookings are assigned to this schedule yet. - - ) : ( - <> - - What boards and alights at each stop. Confirm loading at a booking's - origin and unloading at its destination while the train is at that - yard — unloading stamps the booking's own arrival, even before the - train's final stop. - - {yards.map((yard, index) => { - const trainHere = trainAtYardId === yard.yardId; - return ( - - {index > 0 && } - - {yard.yard} - {trainHere && ( - } - > - Train here - - )} - - - - Board here - - load.mutate({ scheduleId, bookingId })} - onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })} - pendingBookingId={pendingLoadId} - /> - - - - Alight here - - load.mutate({ scheduleId, bookingId })} - onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })} - pendingBookingId={pendingUnloadId} - /> - - - ); - })} - - )} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 5c128eafc..06d588a11 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -53,7 +53,6 @@ import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvai import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel"; -import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; @@ -1248,7 +1247,6 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> - {scheduleId ? : null} {scheduleId ? (