Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx

333 lines
9.9 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, 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 (
<Box
p="md"
style={{
borderRadius: 14,
height: "100%",
border: `1px solid ${
open
? "var(--mantine-color-edr-green-3)"
: "var(--mantine-color-gray-2)"
}`,
background: open
? "linear-gradient(160deg, var(--mantine-color-edr-green-0) 0%, #ffffff 85%)"
: "var(--mantine-color-body)",
boxShadow: open ? "0 2px 10px rgba(10,111,77,0.10)" : "none",
transition: "border-color 150ms ease, box-shadow 150ms ease",
}}
>
<Stack gap={8} h="100%" justify="space-between">
<Box>
<Group justify="space-between" wrap="nowrap" gap={8}>
{w.direction ? (
<Badge
variant="light"
color={isImport ? "blue" : "teal"}
radius="sm"
size="sm"
>
{isImport ? "Import" : "Export"}
</Badge>
) : (
<span />
)}
<Badge
variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"}
radius="sm"
size="sm"
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
</Badge>
</Group>
<Group gap={6} wrap="nowrap" mt={10}>
<Text fz={15} fw={700} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text fz={15} fw={700} truncate>
{w.destination ?? "—"}
</Text>
</Group>
{w.trainNumber ? (
<Text fz={12} c="dimmed" truncate>
Train {w.trainNumber}
</Text>
) : null}
<Group gap={6} wrap="nowrap" mt={8}>
<CalendarClock size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
<Text fz={12} c="dimmed" truncate>
{windowLabel(w)}
</Text>
</Group>
{w.departureDate ? (
<Text fz={12} c="dimmed">
Departs {fmtDay(w.departureDate)}
</Text>
) : null}
</Box>
{cd ? (
<Box
px={10}
py={6}
style={{
borderRadius: 10,
background: open
? "rgba(10,111,77,0.08)"
: "var(--mantine-color-gray-0)",
}}
>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="xs"
/>
</Box>
) : null}
</Stack>
</Box>
);
}
/**
* 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),
);
// 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 (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<CalendarClock size={18} />
<Box>
<Text fw={700} fz={16}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
Import and export booking windows across all lanes (EAT)
</Text>
</Box>
</Group>
{pageCount > 1 ? (
<Group gap={8} wrap="nowrap">
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Previous windows"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft size={18} />
</ActionIcon>
<Group gap={5} wrap="nowrap">
{Array.from({ length: pageCount }, (_, i) => (
<Box
key={i}
onClick={() => 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",
}}
/>
))}
</Group>
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Next windows"
disabled={safePage >= pageCount - 1}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRight size={18} />
</ActionIcon>
</Group>
) : null}
</Group>
{isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[1, 2, 3].map((i) => (
<Skeleton key={i} height={150} radius="md" />
))}
</SimpleGrid>
) : (
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}
</SimpleGrid>
)}
</Card>
);
}