Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx
Marshal 934ed43ccb feat: enhance GlDjiboutiClearanceListPage with tab navigation and improved filtering
- Added tab navigation for All, Import, Export, and On hold shipments.
- Implemented a new LivePill component to show real-time updates.
- Refactored shipment filtering logic to accommodate new tab functionality.
- Improved UI elements including buttons and text inputs for better user experience.
- Updated styles for table cells to allow text wrapping for long company names.
- Adjusted theme to use Space Grotesk font for headings.
2026-08-20 17:44:52 +00:00

411 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,
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;
/** End of the payment drain tail — pending payments may settle until then. */
paymentDrainEndsAt?: 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: "Finalizing…" },
};
function phaseCountdown(w: WindowRow): {
label: string;
deadline: string;
expiredText: string;
graceDeadline?: string | null;
graceLabel?: string;
} | null {
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
// Once the pay deadline lapses, pending payments still settle during the
// drain tail — count it down as "processing" instead of a stale "closing".
const grace =
state.kind === "PAYMENT" && w.paymentDrainEndsAt
? {
graceDeadline: w.paymentDrainEndsAt,
graceLabel: "Processing payments — closes in",
}
: undefined;
return { ...text, deadline: state.countdownTo, ...grace };
}
/** 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}
graceDeadline={cd.graceDeadline}
graceLabel={cd.graceLabel}
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="center" mb="md" wrap="nowrap">
<Group gap={10} wrap="nowrap">
<div className="flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-edr-soft text-edr-primary-dark">
<CalendarClock size={16} />
</div>
<Box>
<Text ff="heading" fw={600} fz={15} lh={1.2}>
Booking windows
</Text>
<Text fz={12} c="edr-muted">
{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>
);
}