import { useMemo, useState } from "react"; import { isAxiosError } from "axios"; import { Badge, Box, Button, Group, Modal, Paper, Progress, ScrollArea, Select, Stack, Text, ThemeIcon, Tooltip, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeftRight, ArrowRight, CheckCircle2, Inbox, Landmark, MapPin, PackageCheck, PackageOpen, // Repeat, // used by the hidden Move (reassign) button Train, TrainFront, Truck, Weight, X, } from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { api } from "@/services/api"; import { bookingsService } from "@/services/bookings.service"; import { useToast } from "@/hooks/use-toast"; import type { EligibleContainerBooking, FreightType, TrainScheduleDetail, YardWorkBookingRow, } from "@/types/trainScheduling"; interface ScheduleWorkspacePanelProps { schedule: TrainScheduleDetail; /** Refetch the schedule detail after a mutation so both panels refresh. */ onChanged: () => void; } const GREEN = "var(--mantine-color-edr-green-6)"; /** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */ function apiErrorMessage(error: unknown, fallback: string): string { if (isAxiosError(error)) { const data = error.response?.data as Record | undefined; const violations = data?.violations; if (Array.isArray(violations) && violations.length) return violations.join(", "); if (typeof data?.message === "string") return data.message; if (Array.isArray(data?.message)) return (data.message as string[]).join(", "); } return fallback; } /** * Deadline + label for the window phase this schedule is currently in. * Phases run: window open (windowClosesAt) → document review (docReviewEndsAt) * → payment (paymentPhaseEndsAt). Display only. Returns null off-phase. */ function phaseCountdown( schedule: TrainScheduleDetail, ): { label: string; deadline: string; expiredText: string } | null { switch (schedule.windowPhase) { case "PRE_WINDOW": return schedule.windowOpensAt ? { label: "Booking window opens in", deadline: schedule.windowOpensAt, expiredText: "Booking opening now…", } : null; case "OPEN": return schedule.windowClosesAt ? { label: "Booking window closes in", deadline: schedule.windowClosesAt, expiredText: "Document review starting…", } : null; case "DOC_REVIEW": return schedule.docReviewEndsAt ? { label: "Document review ends in", deadline: schedule.docReviewEndsAt, expiredText: "Payment starting…", } : null; case "PAYMENT": return schedule.paymentPhaseEndsAt ? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt, expiredText: "Payment window closing…", } : null; default: return null; } } /** * GROSS weight the locomotives actually haul: the HEAVIEST LEG, never the * whole-route sum — disjoint legs (Mojo→Dire + Dire→Doraleh) are pulled one * at a time, so summing every booking over-reports a multi-stop train. * Prefers the API's consist-derived heaviestLeg; before allocation it falls * back to a per-leg max over the bookings (same span math as the header strip). */ function usedWeight(schedule: TrainScheduleDetail): number { const consist = schedule.trainSet?.heaviestLeg?.grossWeightTons; if (consist != null) return Number(consist) || 0; const bookings = schedule.bookings ?? []; const stops = schedule.stops ?? []; if (stops.length <= 2) { return bookings.reduce((sum, b) => sum + (Number(b.weightTons) || 0), 0); } const indexOf = new Map(stops.map((s, i) => [s.yardId, i])); const lastIdx = stops.length - 1; let heaviest = 0; for (let edge = 0; edge < lastIdx; edge += 1) { let legTons = 0; for (const b of bookings) { const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0; const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx; const to = toRaw != null && toRaw > from ? toRaw : lastIdx; if (from <= edge && edge < to) legTons += Number(b.weightTons) || 0; } heaviest = Math.max(heaviest, legTons); } return heaviest; } /** * Pull capacity of the set. Locomotive pull weights ADD UP (they haul * together), so prefer the API's maxGrossWeightTons — the combined set limit * incl. overage tolerance, the same ceiling the validator holds each leg to — * and fall back to summing the locos' own limits. */ function pullCapacity(schedule: TrainScheduleDetail): number { if (schedule.maxGrossWeightTons != null) return Number(schedule.maxGrossWeightTons) || 0; const set = schedule.trainSet; if (!set) return 0; const locos = set.locomotives && set.locomotives.length > 0 ? set.locomotives : set.locomotive ? [set.locomotive] : []; return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0); } export function ScheduleWorkspacePanel({ schedule, onChanged, }: ScheduleWorkspacePanelProps) { const { toast } = useToast(); const freightType: FreightType | undefined = schedule.freightType === "CONTAINER" || schedule.freightType === "BULK" ? schedule.freightType : undefined; 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). const poolQuery = useQuery( api.trainScheduling.eligibleBookings.queryOptions({ input: { filters: { originStationId: schedule.originStation?.id, destinationStationId: schedule.destinationStation?.id, trainScheduleId: schedule.id, }, freightType, }, enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id), }), ); const onTrainIds = useMemo( () => new Set((schedule.bookings ?? []).map((b) => b.id)), [schedule.bookings], ); const pool: EligibleContainerBooking[] = useMemo( () => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)), [poolQuery.data, onTrainIds], ); 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 loadJourney = useMutation( api.trainScheduling.loadScheduleBooking.mutationOptions(), ); const unloadJourney = useMutation( api.trainScheduling.unloadScheduleBooking.mutationOptions(), ); const moveSchedule = useMutation( api.trainScheduling.moveBookingSchedule.mutationOptions(), ); const [moveBookingId, setMoveBookingId] = useState(null); const [moveTarget, setMoveTarget] = useState(null); // Pool → pick a same-day schedule with free wagons and place the booking there. const [poolAssign, setPoolAssign] = useState<{ id: string; reference: string } | null>( null, ); const [poolTarget, setPoolTarget] = useState(null); const { data: targets } = useQuery( api.trainScheduling.bookableSchedules.queryOptions({ input: { originYardId: schedule.originStation?.id, destinationYardId: schedule.destinationStation?.id, }, enabled: Boolean( schedule.originStation?.id && schedule.destinationStation?.id, ), }), ); const moveOptions = useMemo( () => (targets ?? []) .filter((s) => s.id !== schedule.id) .map((s) => ({ value: s.id, label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date( s.scheduleDate, ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`, })), [targets, schedule.id], ); // Every schedule departing on THIS train's day (EAT) — a paid booking waiting // for a wagon may board any of them, so staff pick whichever has wagons free. const eatDayOf = (iso: string) => new Date(iso).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa" }); const sameDayOptions = useMemo(() => { const day = eatDayOf(schedule.scheduledDepartureDate); return (targets ?? []) .filter((s) => eatDayOf(s.scheduleDate) === day) .map((s) => ({ value: s.id, label: `${s.id === schedule.id ? "This train · " : ""}${ s.routeName ?? `${s.origin} → ${s.destination}` } · ${s.remainingWagons}/${s.maxWagons} wagons free`, })); }, [targets, schedule.id, schedule.scheduledDepartureDate]); // ── Capacity meter (by cargo weight vs locomotive pull) ──────────────────── const used = usedWeight(schedule); const capacity = pullCapacity(schedule); const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0; const over = capacity > 0 && used > capacity; const forceAdd = (bookingId: string, ref: string, weightTons: number) => { const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity; assign .mutateAsync({ id: schedule.id, freightType, payload: { bookingIds: [...onTrainIds, bookingId], forceAssign: true, }, }) .then(() => { toast({ title: `${ref} added to train`, description: wouldOverfill ? "Force-added past the pull-weight limit — review capacity." : "Wagons auto-pinned.", variant: wouldOverfill ? "destructive" : undefined, }); onChanged(); void poolQuery.refetch(); }) .catch((error) => toast({ title: "Could not add booking", description: apiErrorMessage(error, "Validation failed — check capacity and status."), variant: "destructive", }), ); }; const removeFromTrain = (bookingId: string, ref: string) => { unassign .mutateAsync({ id: schedule.id, bookingId }) .then(() => { toast({ title: `${ref} removed from train` }); onChanged(); void poolQuery.refetch(); }) .catch((error) => toast({ title: "Could not remove booking", description: apiErrorMessage(error, "Please try again."), variant: "destructive", }), ); }; // 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: `${ref} loaded onto the train` }); onChanged(); void yardWorkQuery.refetch(); }) .catch((error) => toast({ title: "Could not load cargo", description: apiErrorMessage(error, "Train may not be at the boarding yard."), variant: "destructive", }), ); }; // Export cargo that skipped the warehouse (customer truck straight onto the // wagon) has no GRN and never will — loadBooking's GRN gate would keep // rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage // acceptance sheet is the handover document instead, then loads in one click. const [truckToTrainPending, setTruckToTrainPending] = useState(null); const doTruckToTrain = (bookingId: string, ref: string) => { setTruckToTrainPending(bookingId); bookingsService .setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN") .then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId })) .then(() => { toast({ title: `${ref} loaded — direct truck-to-train handover` }); onChanged(); void yardWorkQuery.refetch(); }) .catch((error) => toast({ title: "Could not load as direct truck-to-train", description: apiErrorMessage(error, "Please try again."), variant: "destructive", }), ) .finally(() => setTruckToTrainPending(null)); }; 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", }), ); }; // Point the pool booking at the chosen same-day train, then put it on wagons. // If the wagon step fails (that train is short too) the booking stays paid & // unassigned in the pool — nothing is lost, staff just pick another train. const doPoolAssign = () => { if (!poolAssign || !poolTarget) return; const { id: bookingId, reference } = poolAssign; moveSchedule .mutateAsync({ bookingId, trainScheduleId: poolTarget }) .then(() => assignUnassigned.mutateAsync({ id: poolTarget, bookingId })) .then(() => { toast({ title: `${reference} assigned`, description: "Booking placed on the selected train with wagons pinned.", }); setPoolAssign(null); onChanged(); void poolQuery.refetch(); }) .catch((error) => toast({ title: `Could not assign ${reference}`, description: apiErrorMessage( error, "The selected train has no free wagon of the required type.", ), variant: "destructive", }), ); }; const doMove = () => { if (!moveBookingId || !moveTarget) return; moveSchedule .mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget }) .then(() => { toast({ title: "Booking reassigned to another train" }); setMoveBookingId(null); onChanged(); void poolQuery.refetch(); }) .catch((error) => toast({ title: "Could not reassign booking", description: apiErrorMessage(error, "Target train may be closed or full."), variant: "destructive", }), ); }; return ( {/* Header + capacity meter */}
Allocation workspace Add or remove bookings, then load each one when the train is at its boarding yard
Load {used.toFixed(1)}T {capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""} {(schedule.stops?.length ?? 0) > 2 ? " · heaviest leg" : ""} {over ? ( Over capacity ) : ( {capacity > 0 ? `${pct}%` : "—"} )} 0 ? pct : 0} color={over ? "red" : pct > 85 ? "orange" : "edr-green"} radius="xl" size="md" />
{(() => { const cd = phaseCountdown(schedule); return cd ? ( ) : null; })()} {over ? ( This train is loaded beyond its locomotive pull weight. Force-adds are allowed, but review before dispatch. ) : null} {locked ? ( This train is {schedule.status.toLowerCase()} — bookings can no longer be added or removed. {schedule.status === "DISPATCHED" ? " Loading continues per yard as checkpoints are logged on the track page." : ""} ) : null} {/* Loading confirmation gate removed: bookings can board mid-corridor, so per-yard loading happens from the track page's log-pass flow. */} {/* Two-panel board */} {/* Pool */} {pool.map((b) => ( ) : null } /> ))} {/* 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. */} {corridorGroups.map((group) => { const groupIdx = stationIdx.get(group.yardId) ?? 0; const trainHere = trainAtYardId === group.yardId; const passed = trainIdx != null && groupIdx < trainIdx; return ( {group.label} {trainHere ? ( } > Train here ) : passed ? ( Passed ) : ( Ahead )} {group.rows.length} {group.rows.map((b) => { const ref = b.reference ?? b.id.slice(0, 8); const journey = journeyById.get(b.id); const riding = b.status === "IN_TRANSIT"; const done = ["ARRIVED", "COMPLETED", "DELIVERED"].includes( b.status ?? "", ); const boardHere = trainHere; const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId; const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false); const showUnload = canWork && riding && (journey?.canUnload ?? false); const showTruckToTrain = canWork && !riding && !done && boardHere && b.tradeDirection === "EXPORT"; return ( {showLoad ? ( ) : null} {showTruckToTrain ? ( ) : null} {showUnload ? ( ) : null} {canManage && !riding && !done ? ( journey?.isGovernment ? null : ( ) ) : null} } /> ); })} ); })}
{/* Pool → same-day train assignment modal */} setPoolAssign(null)} title={ Assign {poolAssign?.reference ?? "booking"} to a train on this day } centered radius="lg" > All open trains departing on this schedule's day. Pick one with free wagons — the booking is placed and its wagons pinned in one step.
); } // ── Sub-components ─────────────────────────────────────────────────────────── function PanelColumn({ title, hint, count, accent, loading, emptyIcon: EmptyIcon, emptyText, children, }: { title: string; hint: string; count: number; accent: string; loading?: boolean; emptyIcon: typeof Inbox; emptyText: string; children: React.ReactNode; }) { const isEmpty = !loading && count === 0; return ( {title} {count} {hint} {isEmpty ? ( {emptyText} ) : ( {loading ? ( Loading… ) : ( children )} )} ); } function BookingCard({ reference, customer, weightTons, status, loadingStatus, waitingForWagon, intercity, government, leg, right, }: { reference: string; customer?: string | null; weightTons?: number | null; status?: string | null; loadingStatus?: "LOADED" | "UNLOADED"; /** Paid, but no wagon of the required type was free — waiting for one. */ 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; }) { return ( { e.currentTarget.style.borderColor = GREEN; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)"; }} > {reference} {status ? : null} {intercity ? ( Intercity ) : null} {government ? ( } > Government ) : null} {waitingForWagon ? ( Waiting for wagon ) : null} {loadingStatus ? ( {loadingStatus === "LOADED" ? "Loaded" : "Unloaded"} ) : null} {customer ?? "—"} {weightTons != null ? ( {Number(weightTons).toFixed(1)}T ) : null} {leg ? ( {leg} ) : null} {right ? {right} : null} ); }