mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
enhance booking windows section with pagination and improved UI
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
@@ -365,8 +366,11 @@ export default function GlCreateBookingForm() {
|
||||
(!needsRouteSelect || Boolean(contractRouteId)) &&
|
||||
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!scheduledDate || !contract || !windowOpen) return;
|
||||
/** The create-booking DTO from the current form state — shared by the
|
||||
* authoritative price preview and the actual submit so what GL confirms is
|
||||
* exactly what gets booked. */
|
||||
const buildPayload = (): Freight.CreateBookingUnderContractDto | null => {
|
||||
if (!scheduledDate || !contract) return null;
|
||||
|
||||
const payload: Freight.CreateBookingUnderContractDto = {
|
||||
scheduledDate,
|
||||
@@ -408,6 +412,56 @@ export default function GlCreateBookingForm() {
|
||||
}));
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
// Authoritative price preview (same pricing pass the booking persists at
|
||||
// create): rail freight + first/last mile + overweight + every surcharge.
|
||||
// Fired when the price modal opens; the modal falls back to the contract
|
||||
// unit-rate estimate while it loads.
|
||||
const validateShipmentMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
contractsService.validateShipment(id ?? "", dto),
|
||||
});
|
||||
const validation = validateShipmentMutation.data ?? null;
|
||||
|
||||
const serverTotal = useMemo(() => {
|
||||
const items = validation?.lineItems;
|
||||
if (!items?.length) return null;
|
||||
return {
|
||||
currency: validation?.currency ?? priceTotal?.currency ?? "ETB",
|
||||
lines: items.map((li) => ({
|
||||
label: li.description,
|
||||
unitPrice: li.unitAmount,
|
||||
unit: li.unit.toLowerCase(),
|
||||
quantity: li.quantity,
|
||||
amount: li.amount,
|
||||
})),
|
||||
total:
|
||||
validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
|
||||
};
|
||||
}, [validation, priceTotal]);
|
||||
|
||||
const displayTotal = serverTotal ?? priceTotal;
|
||||
const pairingErrors = validation?.pairingErrors ?? [];
|
||||
const overweightLines = validation?.overweightLines ?? [];
|
||||
|
||||
const openPriceModal = () => {
|
||||
setPriceOpen(true);
|
||||
const payload = buildPayload();
|
||||
if (payload) {
|
||||
validateShipmentMutation.reset();
|
||||
validateShipmentMutation.mutate(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!contract || !windowOpen) return;
|
||||
// Never book past unresolved 20ft pairing hard-blocks.
|
||||
if (pairingErrors.length > 0) return;
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
|
||||
mutations.createBooking.mutate(payload, {
|
||||
onSuccess: async (booking) => {
|
||||
if (requestId) {
|
||||
@@ -819,7 +873,7 @@ export default function GlCreateBookingForm() {
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => setPriceOpen(true)}
|
||||
onClick={openPriceModal}
|
||||
>
|
||||
Review price & book
|
||||
</Button>
|
||||
@@ -850,11 +904,67 @@ export default function GlCreateBookingForm() {
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{priceTotal ? (
|
||||
{displayTotal ? (
|
||||
<Stack gap="md">
|
||||
{validateShipmentMutation.isPending && (
|
||||
<Group gap={8} c="dimmed">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
<Text fz="sm" c="dimmed">
|
||||
Computing the final price breakdown and checking container
|
||||
weights…
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{pairingErrors.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Cannot create booking — 20ft wagon pairing"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{pairingErrors.map((msg, i) => (
|
||||
<Text key={i} fz="sm" c="red.8">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="red.7" mt={2}>
|
||||
Adjust the 20ft container weights or quantities so pairs
|
||||
differ by no more than 10 tons.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overweightLines.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Overweight containers"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{overweightLines.map((line, i) => (
|
||||
<Text key={i} fz="sm" c="#9A5B00">
|
||||
{line.containerTypeCode}: {line.totalVgmTons}t exceeds
|
||||
limit {line.maxAllowedTons}t (+{line.excessTons}t
|
||||
overweight)
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||
An overweight surcharge applies (included in the total
|
||||
below).
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Stack gap={10}>
|
||||
{priceTotal.lines.map((line, i) => (
|
||||
{displayTotal.lines.map((line, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="sm" fw={500}>
|
||||
@@ -862,16 +972,16 @@ export default function GlCreateBookingForm() {
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{line.quantity.toLocaleString()} ×{" "}
|
||||
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
|
||||
{line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "}
|
||||
{formatRateUnit(line.unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{line.amount.toLocaleString()} {priceTotal.currency}
|
||||
{line.amount.toLocaleString()} {displayTotal.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{priceTotal.lines.length === 0 && (
|
||||
{displayTotal.lines.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
No priced lines — check the cargo details.
|
||||
</Text>
|
||||
@@ -889,9 +999,9 @@ export default function GlCreateBookingForm() {
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} fz={28}>
|
||||
{priceTotal.total.toLocaleString()}{" "}
|
||||
{displayTotal.total.toLocaleString()}{" "}
|
||||
<Text span fz={16} fw={700} c="dimmed">
|
||||
{priceTotal.currency}
|
||||
{displayTotal.currency}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -912,6 +1022,9 @@ export default function GlCreateBookingForm() {
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={mutations.createBooking.isPending}
|
||||
disabled={
|
||||
validateShipmentMutation.isPending || pairingErrors.length > 0
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Confirm & book
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core";
|
||||
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 } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
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", {
|
||||
@@ -28,7 +45,7 @@ function fmtTime(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function windowLabel(w: BatchBoardSchedule): string {
|
||||
function windowLabel(w: StaffBookingWindow): string {
|
||||
if (w.windowOpensAt && w.windowClosesAt) {
|
||||
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime(
|
||||
w.windowClosesAt,
|
||||
@@ -42,36 +59,33 @@ function windowLabel(w: BatchBoardSchedule): string {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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: BatchBoardSchedule,
|
||||
w: StaffBookingWindow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
switch (w.windowPhase) {
|
||||
case "PRE_WINDOW":
|
||||
return w.windowOpensAt
|
||||
? {
|
||||
label: "Booking opens in",
|
||||
label: "Opens in",
|
||||
deadline: w.windowOpensAt,
|
||||
expiredText: "Booking opening now…",
|
||||
expiredText: "Opening now…",
|
||||
}
|
||||
: null;
|
||||
case "OPEN":
|
||||
return w.windowClosesAt
|
||||
? {
|
||||
label: "Window closes in",
|
||||
label: "Closes in",
|
||||
deadline: w.windowClosesAt,
|
||||
expiredText: "Document review starting…",
|
||||
expiredText: "Review starting…",
|
||||
}
|
||||
: null;
|
||||
case "DOC_REVIEW":
|
||||
return w.docReviewEndsAt
|
||||
? {
|
||||
label: "Document review ends in",
|
||||
label: "Doc review ends in",
|
||||
deadline: w.docReviewEndsAt,
|
||||
expiredText: "Payment starting…",
|
||||
}
|
||||
@@ -79,9 +93,9 @@ function phaseCountdown(
|
||||
case "PAYMENT":
|
||||
return w.paymentPhaseEndsAt
|
||||
? {
|
||||
label: "Payment window ends in",
|
||||
label: "Payment ends in",
|
||||
deadline: w.paymentPhaseEndsAt,
|
||||
expiredText: "Payment window closing…",
|
||||
expiredText: "Closing…",
|
||||
}
|
||||
: null;
|
||||
default:
|
||||
@@ -89,15 +103,11 @@ function phaseCountdown(
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
function isPast(w: StaffBookingWindow): 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;
|
||||
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;
|
||||
@@ -105,16 +115,121 @@ function isPast(w: BatchBoardSchedule): boolean {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.batchBoard.queryOptions({ refetchInterval: 60_000 }),
|
||||
api.trainScheduling.allBookingWindows.queryOptions({
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const windows = useMemo(() => {
|
||||
const rows = (data ?? []).filter(
|
||||
@@ -122,7 +237,7 @@ export function GlUpcomingWindowsSection() {
|
||||
);
|
||||
// Open lanes first, then by opening time.
|
||||
return rows.sort((a, b) => {
|
||||
const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a));
|
||||
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;
|
||||
@@ -130,120 +245,87 @@ export function GlUpcomingWindowsSection() {
|
||||
});
|
||||
}, [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 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 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 ? (
|
||||
<Stack gap={8}>
|
||||
{[1, 2].map((i) => (
|
||||
<Skeleton key={i} height={58} radius="md" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height={150} radius="md" />
|
||||
))}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -201,6 +201,7 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
||||
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
|
||||
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
||||
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
|
||||
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
|
||||
@@ -295,6 +296,7 @@ export const URL_CONSTANTS = {
|
||||
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
|
||||
PREVIEW: "/train-scheduling/preview",
|
||||
ASSIGN_BOOKINGS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/assign-bookings`,
|
||||
|
||||
@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
return {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
contractReference: booking.contractReference ?? null,
|
||||
approvalSteps: booking.approvalSteps,
|
||||
customerLabel: booking.isGovernment
|
||||
? (booking.governmentInstitution ?? "Government")
|
||||
|
||||
@@ -78,7 +78,13 @@ export function useBookingMutations(bookingId: string) {
|
||||
note?: string;
|
||||
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
|
||||
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
|
||||
onError: () => toast.error("Failed to review operation request"),
|
||||
onError: (error) => {
|
||||
toast.error(parseApiError(error, "Failed to review operation request"));
|
||||
// The transition may have committed even when the response errored (e.g.
|
||||
// a post-accept step failed). Refetch so the UI shows the true state
|
||||
// instead of requiring a manual refresh.
|
||||
void invalidateBookingDetail(qc, bookingId);
|
||||
},
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
|
||||
@@ -149,7 +149,8 @@ export default function BookingRequestsPage() {
|
||||
return items.filter(
|
||||
(b) =>
|
||||
b.reference.toLowerCase().includes(q) ||
|
||||
b.customerLabel.toLowerCase().includes(q),
|
||||
b.customerLabel.toLowerCase().includes(q) ||
|
||||
(b.contractReference?.toLowerCase().includes(q) ?? false),
|
||||
);
|
||||
}, [data?.items, query]);
|
||||
|
||||
@@ -196,6 +197,22 @@ export default function BookingRequestsPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const ref = row.original.contractReference;
|
||||
return (
|
||||
<div className="py-1">
|
||||
{ref ? (
|
||||
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
@@ -373,7 +390,7 @@ export default function BookingRequestsPage() {
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
placeholder="Search booking, contract or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
StaffBookingWindow,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleFilters,
|
||||
TrainScheduleListItem,
|
||||
@@ -223,6 +224,13 @@ export const api = {
|
||||
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
),
|
||||
|
||||
allBookingWindows: endpoint<void, StaffBookingWindow[]>(
|
||||
"train-scheduling",
|
||||
"all-booking-windows",
|
||||
() => trainSchedulingService.getAllBookingWindows(),
|
||||
() => ["train-scheduling", "all-booking-windows"],
|
||||
),
|
||||
|
||||
batchBoardDetail: endpoint<
|
||||
{ scheduleId: string },
|
||||
BatchBoardScheduleDetail
|
||||
|
||||
@@ -27,6 +27,39 @@ export interface PaginatedContracts {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */
|
||||
export interface ShipmentPriceLine {
|
||||
code: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
unitAmount: number;
|
||||
/** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */
|
||||
unit: string;
|
||||
quantity: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-create validation + authoritative price preview for a booking under a
|
||||
* contract. `lineItems`/`totalAmount` are the full server-computed breakdown —
|
||||
* the same pricing pass the booking persists at create (rail freight,
|
||||
* first/last mile, overweight and every other surcharge). `pairingErrors` are
|
||||
* HARD BLOCKS; `overweightLines` are warnings.
|
||||
*/
|
||||
export interface ShipmentValidation {
|
||||
overweightLines: Array<{
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}>;
|
||||
overweightSurchargeAmount: number;
|
||||
currency: string | null;
|
||||
pairingErrors: string[];
|
||||
lineItems?: ShipmentPriceLine[];
|
||||
totalAmount?: number;
|
||||
}
|
||||
|
||||
export interface ContractListSummaryMetrics {
|
||||
inQueue: number;
|
||||
needsAction: number;
|
||||
@@ -471,6 +504,18 @@ export const contractsService = {
|
||||
payload: Freight.CreateBookingUnderContractDto,
|
||||
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
|
||||
|
||||
/**
|
||||
* Pre-create validation + authoritative price preview: the same
|
||||
* BookingPricingService pass that prices the booking on create (rail +
|
||||
* first/last mile + every surcharge), plus overweight warnings and 20ft
|
||||
* pairing hard-blocks. Shown in the GL price-confirm modal.
|
||||
*/
|
||||
validateShipment: (
|
||||
id: string,
|
||||
payload: Freight.CreateBookingUnderContractDto,
|
||||
) =>
|
||||
postContract<ShipmentValidation>(C.VALIDATE_SHIPMENT(id), payload),
|
||||
|
||||
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
|
||||
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
||||
const response = await client.get(C.CAPACITY(id));
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
StaffBookingWindow,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleFilters,
|
||||
TrainScheduleListItem,
|
||||
@@ -543,6 +544,13 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getAllBookingWindows: async (): Promise<StaffBookingWindow[]> => {
|
||||
const response = await client.get<StaffBookingWindow[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOWS,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateGlobalRules: async (
|
||||
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
|
||||
): Promise<TrainSchedulingGlobalRules> => {
|
||||
|
||||
@@ -191,6 +191,9 @@ export interface BookingDetail {
|
||||
customsClearingEnabled?: boolean;
|
||||
customsClearingAgent?: string | null;
|
||||
contractKind?: "ONE_TIME" | "GENERAL" | null;
|
||||
contractId?: string | null;
|
||||
/** Reference of the contract this booking was created under (list column + search). */
|
||||
contractReference?: string | null;
|
||||
contractSummary?: string | null;
|
||||
latestChangeRequestNote?: string | null;
|
||||
nextStep?: BookingNextStep | null;
|
||||
@@ -218,6 +221,7 @@ export interface BookingDetail {
|
||||
export interface BookingListRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractReference?: string | null;
|
||||
customerLabel: string;
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
status: BookingStatus;
|
||||
|
||||
@@ -226,6 +226,27 @@ export interface BatchBoardBooking {
|
||||
state: BatchBoardBookingState;
|
||||
}
|
||||
|
||||
/**
|
||||
* An announced booking window on any lane (import cycle or export FCFS), for
|
||||
* staff dashboards. Mirrors the customer portal's MyBookingWindow.
|
||||
*/
|
||||
export interface StaffBookingWindow {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
direction: "IMPORT" | "EXPORT" | null;
|
||||
windowPhase: BookingWindowPhase | 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;
|
||||
}
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
|
||||
Reference in New Issue
Block a user