import { useState } from "react"; import { Alert, Badge, Button, Checkbox, Group, Loader, Paper, Stack, Table, Text, Tooltip, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { IntercityBookingRow, IntercityCapacity, } from "@/types/trainScheduling"; const parseError = (error: unknown, fallback: string) => { 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; }; // A capacity axis can be null when the schedule's locomotive has no limit // configured for it — render "—" instead of crashing on toFixed. function fmt(n: number | null | undefined): string { if (n == null) return "—"; return Number.isInteger(n) ? String(n) : n.toFixed(1); } function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) { if (!capacity) { return ( Capacity unknown — schedule has no locomotive/train set yet. ); } return ( 0 ? "teal" : "red"} > {fmt(capacity.wagons)} wagons free 0 ? "teal" : "red"} > {fmt(capacity.weightTons)} t free 0 ? "teal" : "red"} > {fmt(capacity.lengthMeters)} m free ); } function NeedCells({ need }: { need: IntercityCapacity | null }) { if (!need) return ; return ( <> {fmt(need.wagons)} {fmt(need.weightTons)} t {fmt(need.lengthMeters)} m ); } function CorridorCell({ row }: { row: IntercityBookingRow }) { return ( {row.origin} {row.destination} ); } /** * Intercity ride-along desk for one import/export schedule: waiting intercity * bookings whose corridor lies on this train's route, checked against the * remaining wagon/weight/length budget. Accepting opens the customer's pay * window; after payment the booking is allocated. Loading/unloading is * confirmed manually when the train is physically at the booking's origin / * destination yard (the server validates against recorded checkpoints). */ /** Plain-language journey states for the accepted ride-along table. */ const INTERCITY_STATUS_META: Record = { SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" }, APPROVED: { label: "Ready to load (gov)", color: "edr-green" }, PAID: { label: "Paid — ready to load", color: "edr-green" }, IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" }, COMPLETED: { label: "Delivered", color: "teal" }, }; export function IntercityRideAlongPanel({ scheduleId, direction, }: { scheduleId: string; direction: string | null | undefined; }) { const { toast } = useToast(); const queryClient = useQueryClient(); const [selected, setSelected] = useState([]); const candidatesQuery = useQuery( api.trainScheduling.intercityCandidates.queryOptions({ input: { scheduleId }, refetchInterval: 60_000, }), ); // Accepting/loading/unloading a ride-along changes the schedule's booking // list, the yard worklists AND this panel — refresh all three so the // workspace board and yard-work tables never show a stale picture. const invalidate = () => { void queryClient.invalidateQueries({ queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }), }); void queryClient.invalidateQueries({ queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }), }); void queryClient.invalidateQueries({ queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }), }); }; const accept = useMutation( api.trainScheduling.acceptIntercityBookings.mutationOptions({ onSuccess: (result) => { setSelected([]); void invalidate(); if (result.accepted.length > 0) { toast({ title: `${result.accepted.length} intercity booking(s) accepted`, description: "Customers have been asked to pay.", }); } for (const r of result.rejected) { toast({ title: "Booking skipped", description: r.reason, variant: "destructive", }); } }, onError: (err) => toast({ title: "Accept failed", description: parseError(err, "Could not accept intercity bookings"), variant: "destructive", }), }), ); const load = useMutation( api.trainScheduling.loadIntercityBooking.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.unloadIntercityBooking.mutationOptions({ onSuccess: () => { void invalidate(); toast({ title: "Cargo unloaded — booking completed" }); }, onError: (err) => toast({ title: "Unload failed", description: parseError(err, "Could not confirm unloading"), variant: "destructive", }), }), ); // Intercity bookings only ride import/export trains. if (direction !== "IMPORT" && direction !== "EXPORT") return null; const data = candidatesQuery.data; const candidates = data?.candidates ?? []; const accepted = data?.accepted ?? []; if (candidatesQuery.isLoading) { return ( Loading intercity ride-along bookings… ); } if (candidates.length === 0 && accepted.length === 0) return null; return ( Intercity ride-along {candidates.length > 0 && ( <> Waiting intercity bookings whose corridor lies on this train's route. Accepting opens the customer's payment window against the free capacity above. Booking Customer Corridor Wagons Weight Length Fits {candidates.map((row) => ( setSelected((prev) => e.currentTarget.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id), ) } /> {row.reference ?? row.id.slice(0, 8)} {row.isGovernment && ( GOV )} {row.customer} {row.fits ? ( Fits ) : ( No room )} ))}
)} {accepted.length > 0 && ( <> On this train Booking Customer Corridor Status {accepted.map((row) => ( {row.reference ?? row.id.slice(0, 8)} {row.customer} {INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status} {row.status === "PAID" && ( )} {row.status === "IN_TRANSIT" && ( )} ))}
)} {candidatesQuery.isError && ( }> {parseError(candidatesQuery.error, "Could not load intercity candidates")} )}
); }