mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
- 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.
435 lines
13 KiB
TypeScript
435 lines
13 KiB
TypeScript
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'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'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>
|
||
);
|
||
}
|