mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
- Removed the field from and related components. - Updated to reflect the removal of the reopen delay input field. - Modified to include new train number fields: and . - Added interface to manage active schedules with trade direction. - Introduced interface to track wagon shortages in bookings. - Updated logic to ensure consistent UI state representation. - Created migrations to drop the column and add and columns to the table. - Added tests for the new booking window display logic and wagon planning functionality.
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
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<BookingWindowUiKind, { label: string; expiredText: string }>
|
||
> = {
|
||
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<BookingWindowUiKind, { label: string; color: string }> = {
|
||
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 (
|
||
<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={badge.color}
|
||
radius="sm"
|
||
size="sm"
|
||
>
|
||
{badge.label}
|
||
</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.reference ? (
|
||
<Text fz={12} fw={600} ff="monospace" c="edr-green.7" truncate>
|
||
{w.reference}
|
||
</Text>
|
||
) : null}
|
||
{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>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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">
|
||
{contractId
|
||
? "Booking windows on this contract's routes (EAT)"
|
||
: "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>
|
||
);
|
||
}
|