import { useMemo, useState } from "react"; import { ActionIcon, Badge, Box, Card, Group, SimpleGrid, Skeleton, Stack, Text, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { ArrowRight, CalendarClock, ChevronLeft, ChevronRight, } from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; /** * The fields a window card needs. Structural so both `StaffBookingWindow` * (all-lanes staff feed) and `BookingWindow` (contract-scoped feed, which * carries no train number) satisfy it. */ interface WindowRow { scheduleId: string; trainNumber?: string | null; direction: string | null; windowPhase: string | null; isOpenNow: boolean; windowOpensAt: string | null; windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; origin: string | null; destination: string | null; } /** All window times are communicated in East Africa Time. */ const TZ = "Africa/Addis_Ababa"; /** Cards visible per carousel page. */ const PER_PAGE = 3; function fmtDay(iso: string): string { return new Date(iso).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: TZ, }); } function fmtTime(iso: string): string { return new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: TZ, }); } function windowLabel(w: WindowRow): string { if (w.windowOpensAt && w.windowClosesAt) { return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( w.windowClosesAt, )} EAT`; } if (w.windowOpensAt) { return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`; } return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " "); } /** * The countdown for whichever phase the window is currently in, mirroring the * customer portal. `expiredText` names the NEXT step so a deadline that lapses * between refetches announces what comes next rather than the bare "Expired". */ function phaseCountdown( w: WindowRow, ): { label: string; deadline: string; expiredText: string } | null { switch (w.windowPhase) { case "PRE_WINDOW": return w.windowOpensAt ? { label: "Opens in", deadline: w.windowOpensAt, expiredText: "Opening now…", } : null; case "OPEN": return w.windowClosesAt ? { label: "Closes in", deadline: w.windowClosesAt, expiredText: "Review starting…", } : null; case "DOC_REVIEW": return w.docReviewEndsAt ? { label: "Doc review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…", } : null; case "PAYMENT": return w.paymentPhaseEndsAt ? { label: "Payment ends in", deadline: w.paymentPhaseEndsAt, expiredText: "Closing…", } : null; default: return null; } } /** * Drop windows the SERVER considers finished — keyed off windowPhase, never the * client clock. The server query already excludes terminal / departed rows; * comparing `Date.now()` here only re-introduced clock skew that made a card * vanish and reappear on refresh. Trust the server phase (live-patched over the * socket) instead. */ function isPast(w: WindowRow): boolean { return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY"; } function WindowCard({ w }: { w: WindowRow }) { const cd = phaseCountdown(w); const open = w.isOpenNow; const isImport = w.direction === "IMPORT"; return ( {w.direction ? ( {isImport ? "Import" : "Export"} ) : ( )} {open ? "Open now" : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} {w.origin ?? "—"} {w.destination ?? "—"} {w.trainNumber ? ( Train {w.trainNumber} ) : null} {windowLabel(w)} {w.departureDate ? ( Departs {fmtDay(w.departureDate)} ) : null} {cd ? ( ) : null} ); } interface GlUpcomingWindowsSectionProps { /** * Scope the card to one contract: only windows on that contract's routes * (and therefore its import/export direction) are shown. Omit for the * all-lanes staff feed on the clearance queue. */ contractId?: string; } /** * Announced booking windows (import cycles + export FCFS) as a paged carousel — * three lanes per page, arrows to flip. Without `contractId` it shows every * lane (GL ET clearance queue); with `contractId` it shows only the windows * matching that contract's routes/direction (clearance detail page). Mirrors * the customer's portal "Booking Windows" card. Hidden when nothing is pending. */ export function GlUpcomingWindowsSection({ contractId, }: GlUpcomingWindowsSectionProps = {}) { // Live pushes flip cards the moment the window engine transitions a phase; // the 60s poll below stays only as a fallback. useBookingWindowSocket(); const allLanes = useQuery({ ...api.trainScheduling.allBookingWindows.queryOptions({ refetchInterval: 60_000, }), enabled: !contractId, }); const contractLanes = useQuery({ ...api.trainScheduling.contractBookingWindows.queryOptions({ input: { contractId: contractId ?? "" }, refetchInterval: 60_000, }), enabled: Boolean(contractId), }); const data: WindowRow[] | undefined = contractId ? contractLanes.data : allLanes.data; const isLoading = contractId ? contractLanes.isLoading : allLanes.isLoading; const [page, setPage] = useState(0); const windows = useMemo(() => { const rows = (data ?? []).filter( (w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w), ); // Canceled schedules are retired to windowPhase='DONE' server-side, so the // guard above already excludes them; they never reach the upcoming list. // Order by the train's dispatch (departure) date, nearest first. Open-now // breaks ties on the same departure. return rows.sort((a, b) => { const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; if (da !== db) return da - db; return Number(b.isOpenNow) - Number(a.isOpenNow); }); }, [data]); const pageCount = Math.max(1, Math.ceil(windows.length / PER_PAGE)); const safePage = Math.min(page, pageCount - 1); const visible = windows.slice( safePage * PER_PAGE, safePage * PER_PAGE + PER_PAGE, ); if (!isLoading && windows.length === 0) return null; return ( Booking windows {contractId ? "Booking windows on this contract's routes (EAT)" : "Import and export booking windows across all lanes (EAT)"} {pageCount > 1 ? ( setPage((p) => Math.max(0, p - 1))} > {Array.from({ length: pageCount }, (_, i) => ( setPage(i)} style={{ width: i === safePage ? 18 : 7, height: 7, borderRadius: 999, cursor: "pointer", background: i === safePage ? "var(--mantine-color-edr-green-6)" : "var(--mantine-color-gray-3)", transition: "width 200ms ease, background 200ms ease", }} /> ))} = pageCount - 1} onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} > ) : null} {isLoading ? ( {[1, 2, 3].map((i) => ( ))} ) : ( {visible.map((w) => ( ))} )} ); }