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 { api } from "@/services/api"; import type { StaffBookingWindow } from "@/types/trainScheduling"; /** 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: StaffBookingWindow): 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: StaffBookingWindow, ): { 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 whose booking window (or the train itself) has already passed. */ function isPast(w: StaffBookingWindow): boolean { const now = Date.now(); const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; const departs = w.departureDate ? new Date(w.departureDate).getTime() : null; // Still live while in a post-close staff phase (doc review / payment). if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false; if (departs != null && departs <= now) return true; if (closes != null && closes <= now) return true; return false; } function WindowCard({ w }: { w: StaffBookingWindow }) { 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} ); } /** * All announced booking windows (import cycles + export FCFS) across every lane, * shown to GL ET on the clearance queue as a paged carousel — three lanes per * page, arrows to flip. Mirrors the customer's portal "Booking Windows" card. * Hidden when nothing is pending. */ export function GlUpcomingWindowsSection() { const { data, isLoading } = useQuery( api.trainScheduling.allBookingWindows.queryOptions({ refetchInterval: 60_000, }), ); 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. // Open lanes first, then by opening time. return rows.sort((a, b) => { const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); if (openDiff !== 0) return openDiff; const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; return at - bt; }); }, [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 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) => ( ))} )} ); }