Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx
2026-07-03 23:30:49 +00:00

251 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo } from "react";
import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, CalendarClock } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { api } from "@/services/api";
import type { BatchBoardSchedule } from "@/types/trainScheduling";
/** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
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: BatchBoardSchedule): 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. Phases run pre-window (opens at windowOpensAt) → open (closes
* at windowClosesAt) → document review (docReviewEndsAt) → payment
* (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that
* lapses between the 60s refetches announces what comes next rather than the
* bare word "Expired". Returns null when no phase is timing down.
*/
function phaseCountdown(
w: BatchBoardSchedule,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Booking opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Window closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Document review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment window ends in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
}
: null;
default:
return null;
}
}
function isOpenNow(w: BatchBoardSchedule): boolean {
return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN";
}
/** Drop windows whose booking window (or the train itself) has already passed. */
function isPast(w: BatchBoardSchedule): boolean {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.scheduleDate ? new Date(w.scheduleDate).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;
}
/**
* Upcoming / open import booking windows across all train schedules, shown to GL
* ET on the clearance queue so they can see which lanes are accepting bookings
* (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is
* pending. Windows already past close/departure are dropped.
*/
export function GlUpcomingWindowsSection() {
const { data, isLoading } = useQuery(
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }),
);
const windows = useMemo(() => {
const rows = (data ?? []).filter(
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
);
// Open lanes first, then by opening time.
return rows.sort((a, b) => {
const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a));
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]);
if (!isLoading && windows.length === 0) return null;
return (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group gap={8} mb="md" wrap="nowrap">
<CalendarClock size={18} />
<Box>
<Text fw={700} fz={16}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
Upcoming and open import booking windows across all lanes (EAT)
</Text>
</Box>
</Group>
{isLoading ? (
<Stack gap={8}>
{[1, 2].map((i) => (
<Skeleton key={i} height={58} radius="md" />
))}
</Stack>
) : (
<ScrollArea.Autosize mah={340} type="hover">
<Stack gap={10} pr={4}>
{windows.map((w) => {
const open = isOpenNow(w);
const cd = phaseCountdown(w);
return (
<Group
key={`${w.scheduleId}-${w.bookingCycleNo}`}
justify="space-between"
wrap="nowrap"
gap={12}
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${
open
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-gray-2)"
}`,
backgroundColor: open
? "var(--mantine-color-edr-green-0)"
: undefined,
}}
>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fz={14} fw={700} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={13} style={{ flexShrink: 0 }} />
<Text fz={14} fw={700} truncate>
{w.destination ?? "—"}
</Text>
{w.trainNumber ? (
<Text fz={12} c="dimmed" truncate>
· {w.trainNumber}
</Text>
) : null}
</Group>
<Text fz={12} c="dimmed" truncate mt={2}>
{windowLabel(w)}
{w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""}
</Text>
{cd ? (
<Box mt={4}>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="xs"
/>
</Box>
) : null}
</Box>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
{w.direction ? (
<Badge
variant="light"
color={w.direction === "IMPORT" ? "blue" : "teal"}
radius="sm"
>
{w.direction === "IMPORT" ? "Import" : "Export"}
</Badge>
) : null}
<Badge
variant={open ? "filled" : "light"}
color={
open
? "edr-green"
: w.windowPhase === "PRE_WINDOW"
? "yellow"
: "gray"
}
radius="sm"
>
{open
? "Open now"
: w.windowPhase === "PRE_WINDOW" && w.windowOpensAt
? `Opens ${fmtTime(w.windowOpensAt)} EAT`
: (w.windowPhase ?? w.bookingWindowStatus).replace(
/_/g,
" ",
)}
</Badge>
</Group>
</Group>
);
})}
</Stack>
</ScrollArea.Autosize>
)}
</Card>
);
}