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, PackageCheck, PackageX, // Repeat, // used by the hidden Move (reassign) button Train, Weight, X, } from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { EligibleContainerBooking, FreightType, TrainScheduleDetail, } 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 } | null { switch (schedule.windowPhase) { case "OPEN": return schedule.windowClosesAt ? { label: "Booking window closes in", deadline: schedule.windowClosesAt } : null; case "DOC_REVIEW": return schedule.docReviewEndsAt ? { label: "Document review ends in", deadline: schedule.docReviewEndsAt } : null; case "PAYMENT": return schedule.paymentPhaseEndsAt ? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt } : null; default: return null; } } /** GROSS weight already on this train (each booking's cargo + wagon tare) — * compared against the locomotive pull limit, which is a gross ceiling. */ function usedWeight(schedule: TrainScheduleDetail): number { return (schedule.bookings ?? []).reduce( (sum, b) => sum + (Number(b.weightTons) || 0), 0, ); } /** * Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when * unknown). The API caps at the weakest loco, not the sum of all locos — a * consist can only pull as hard as its weakest engine. Both sides of this meter * are gross: `usedWeight` sums per-booking gross (cargo + wagon tare). */ function pullCapacity(schedule: TrainScheduleDetail): number { const set = schedule.trainSet; if (!set) return 0; const locos = set.locomotives && set.locomotives.length > 0 ? set.locomotives : set.locomotive ? [set.locomotive] : []; if (locos.length === 0) return 0; return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 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); // 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 ?? []; // ── 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 confirmLoading = useMutation( api.trainScheduling.confirmLoading.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", }), ); }; const toggleLoaded = ( bookingId: string, ref: string, next: "LOADED" | "UNLOADED", ) => { setLoading .mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next }) .then(() => { toast({ title: next === "LOADED" ? `${ref} marked loaded` : `${ref} marked unloaded`, }); onChanged(); }) .catch((error) => toast({ title: "Could not update loading status", description: apiErrorMessage(error, "Please try again."), variant: "destructive", }), ); }; const doConfirmLoading = () => { confirmLoading .mutateAsync({ id: schedule.id }) .then(() => { toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." }); onChanged(); }) .catch((error) => toast({ title: "Could not confirm loading", description: apiErrorMessage( error, "Grant the Djibouti gatepass first, then confirm loading.", ), 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 Manually add paid, unassigned bookings, remove, or reassign them
Load {used.toFixed(1)}T {capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""} {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 changed. ) : null} {/* Loading confirmation — required before dispatch for import-Djibouti trains; shown for every direction so staff have one place to confirm. */} {canManage ? ( {schedule.loadingConfirmed ? ( ) : ( )} {schedule.loadingConfirmed ? "Loading confirmed — cleared to dispatch" : "Confirm loading before dispatching this train"} {!schedule.loadingConfirmed ? ( ) : null} ) : null} {/* Two-panel board */} {/* Pool */} {pool.map((b) => ( ) : null } /> ))} {/* On train */} {onTrain.map((b) => ( {b.wagonAssigned ? ( ) : null} {/* Reassign-to-another-train — hidden for now. */} ) : 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, 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; /** "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} {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} ); }