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;
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 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.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) => (
))}
)}
);
}