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, bookingWindowUiState } from "@edr/ui-common"; import type { BookingWindowUiKind } 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; reference?: string | null; 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 the window's UI state, mirroring the customer portal. * Derived from the SAME state as the badge (`bookingWindowUiState`) so they * can never contradict — a full train shows no ticking countdown. * `expiredText` names the NEXT step so a deadline that lapses between * refetches announces what comes next rather than the bare "Expired". */ const COUNTDOWN_TEXT: Partial< Record > = { PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" }, OPEN: { label: "Closes in", expiredText: "Review starting…" }, DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" }, PAYMENT: { label: "Payment ends in", expiredText: "Closing…" }, }; function phaseCountdown( w: WindowRow, ): { label: string; deadline: string; expiredText: string } | null { const state = bookingWindowUiState(w); const text = COUNTDOWN_TEXT[state.kind]; if (!state.countdownTo || !text) return null; return { ...text, deadline: state.countdownTo }; } /** Badge label + Mantine color per UI state — same state the countdown uses. */ const KIND_BADGE: Record = { OPEN: { label: "Open now", color: "edr-green" }, FULL: { label: "Train full", color: "red" }, PRE_WINDOW: { label: "Opens soon", color: "yellow" }, DOC_REVIEW: { label: "Doc review", color: "gray" }, PAYMENT: { label: "Payment", color: "gray" }, CLOSED: { label: "Closed", color: "gray" }, }; /** * 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 state = bookingWindowUiState(w); const badge = KIND_BADGE[state.kind]; const open = state.isBookable; const isImport = w.direction === "IMPORT"; return ( {w.direction ? ( {isImport ? "Import" : "Export"} ) : ( )} {badge.label} {w.origin ?? "—"} {w.destination ?? "—"} {w.reference ? ( {w.reference} ) : null} {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) => ( ))} )} ); }