Files
edr-platform/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx
Marshal ddddcfb71f Refactor wagon type handling and container wagon calculations
- Removed maxWagonsPerTrain from WagonType entity and related DTOs.
- Updated containerWagonsForLines function to calculate required wagons based on container lines more accurately.
- Added unit tests for containerWagonsForLines to ensure correct calculations.
- Adjusted related services and scripts to reflect the removal of maxWagonsPerTrain.
- Enhanced booking and contract components to use new status labels for better user experience.
- Implemented validation for unique container numbers in shipment forms.
2026-07-09 03:36:35 +00:00

435 lines
13 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,
Group,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import {
ArrowRight,
CalendarClock,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
const INK = "#10202F";
const MUTED = "#6B7C8E";
const BORDER = "#E6ECF2";
/** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
/** Cards visible per carousel page. */
const PER_PAGE = 3;
/** Customer-facing labels for a booking-window phase / status. */
const WINDOW_PHASE_LABELS: Record<string, string> = {
PRE_WINDOW: "Opens soon",
OPEN: "Open now",
DOC_REVIEW: "Document review",
PAYMENT: "Payment due",
DONE: "Closed",
CLOSED_FOR_DAY: "Closed for the day",
};
/** Friendly label for a window phase/status, never the raw enum. */
function windowPhaseLabel(phase?: string | null): string {
if (!phase) return "—";
return (
WINDOW_PHASE_LABELS[phase] ??
phase
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
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,
});
}
/** "Thu, 10 Jul · 08:00 11:00 EAT" (or a phase label when times are unset). */
function windowLabel(w: MyBookingWindow): 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 windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus);
}
/**
* The countdown for whichever phase the window is currently in, mirroring the
* home dashboard's Booking Windows card. `expiredText` names the NEXT step so a
* deadline that lapses between refetches announces what comes next rather than
* the bare "Expired".
*/
function phaseCountdown(
w: MyBookingWindow,
): { 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 due in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
}
: null;
default:
return null;
}
}
/**
* Drop windows the SERVER considers finished. Keyed off the server's windowPhase
* — never the client clock. The server query already excludes terminal
* (DONE / CLOSED_FOR_DAY) and departed rows; comparing `Date.now()` against the
* row's timestamps here only re-introduced clock skew, which made a card vanish
* on one machine and reappear after refresh. So we trust the phase the server
* sends (live-patched over the socket) and let it drive visibility.
*/
function isPast(w: MyBookingWindow): boolean {
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
}
function WindowCard({ w }: { w: MyBookingWindow }) {
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 ? "#CDEBDD" : BORDER}`,
background: open
? "linear-gradient(160deg, #F4FBF7 0%, #FFFFFF 85%)"
: "#FFFFFF",
boxShadow: open ? "0 2px 10px rgba(10,111,77,0.10)" : "none",
}}
>
<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"
: windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
</Badge>
</Group>
<Group gap={6} wrap="nowrap" mt={10}>
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={14} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{w.destination ?? "—"}
</Text>
</Group>
<Group gap={6} wrap="nowrap" mt={8}>
<CalendarClock size={13} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={12} style={{ color: MUTED }} truncate>
{windowLabel(w)}
</Text>
</Group>
{w.departureDate ? (
<Text fz={12} style={{ color: MUTED }}>
Departs {fmtDay(w.departureDate)}
</Text>
) : null}
</Box>
{cd ? (
<Box
px={10}
py={6}
style={{
borderRadius: 10,
background: open ? "rgba(10,111,77,0.08)" : "#F8FAFC",
}}
>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="xs"
/>
</Box>
) : null}
</Stack>
</Box>
);
}
/**
* One-line status strip above the cards: green when a window is open right now
* (the customer can act), neutral with the next opening time otherwise.
*/
function WindowStatusBanner({ windows }: { windows: MyBookingWindow[] }) {
const open = windows.find((w) => w.isOpenNow);
if (open) {
const lane =
open.origin && open.destination
? ` on ${open.origin}${open.destination}`
: "";
return (
<Group
gap={10}
wrap="nowrap"
px={14}
py={10}
mb="md"
style={{
borderRadius: 12,
border: "1px solid #CDEBDD",
background: "#F4FBF7",
}}
>
<CheckCircle2 size={17} color="#0A6F4D" style={{ flexShrink: 0 }} />
<Text fz={13.5} fw={600} c="#0A6F4D">
A booking window is open right now{lane} you can create a shipment
booking before it closes.
</Text>
</Group>
);
}
const next = soonestUpcomingWindow(windows);
return (
<Group
gap={10}
wrap="nowrap"
px={14}
py={10}
mb="md"
style={{
borderRadius: 12,
border: `1px solid ${BORDER}`,
background: "#F8FAFC",
}}
>
<Clock size={17} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={13.5} fw={600} style={{ color: MUTED }}>
{next?.windowOpensAt
? `Booking is not open yet — the next window opens ${formatWindowOpensAt(
next.windowOpensAt,
)} EAT.`
: "Booking is not open right now. You'll see the opening time here once a window is announced."}
</Text>
</Group>
);
}
interface ContractBookingWindowsSectionProps {
/** Windows already scoped to this contract's routes/direction by the API. */
windows: MyBookingWindow[];
isLoading: boolean;
}
/**
* Booking windows on THIS contract's routes only (the API filters by the
* contract's route lanes, which also pins the import/export direction) — the
* contract-scoped counterpart of the home dashboard's all-lanes Booking Windows
* card. Paged three cards at a time; hidden when nothing is announced.
*/
export function ContractBookingWindowsSection({
windows,
isLoading,
}: ContractBookingWindowsSectionProps) {
const [page, setPage] = useState(0);
const sorted = useMemo(() => {
const rows = windows.filter(
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
);
// Order by the train's dispatch (departure) date, nearest first — the
// shipment leaving soonest leads. 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);
});
}, [windows]);
const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE));
const safePage = Math.min(page, pageCount - 1);
const visible = sorted.slice(
safePage * PER_PAGE,
safePage * PER_PAGE + PER_PAGE,
);
return (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<CalendarClock size={18} color={MUTED} />
<Box>
<Text fw={700} fz={16} style={{ color: INK }}>
Booking windows
</Text>
<Text fz={13} style={{ color: MUTED }}>
Windows on this contract&apos;s routes (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 ? "#0A6F4D" : "#D8E2EB",
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>
) : sorted.length === 0 ? (
<Stack
align="center"
gap={6}
py={28}
style={{
borderRadius: 12,
border: `1px dashed ${BORDER}`,
background: "#FBFCFE",
}}
>
<CalendarClock size={22} color={MUTED} />
<Text fz={14} fw={600} style={{ color: INK }}>
No booking windows announced yet
</Text>
<Text fz={12.5} ta="center" maw={420} style={{ color: MUTED }}>
When a train is scheduled on this contract&apos;s routes, its
booking window will appear here with the opening time.
</Text>
</Stack>
) : (
<>
<WindowStatusBanner windows={sorted} />
<SimpleGrid
key={safePage}
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="md"
>
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}
</SimpleGrid>
</>
)}
</Paper>
);
}