Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
yaschalew
2026-07-04 03:26:58 +03:00
7 changed files with 493 additions and 320 deletions

View File

@@ -61,13 +61,14 @@ export class TrainSchedulingController {
@Get("my-booking-windows") @Get("my-booking-windows")
@ApiOperation({ @ApiOperation({
summary: summary:
"Upcoming/open booking windows on the signed-in customer's active contract lanes", "Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)",
}) })
async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) {
// Every customer sees announced windows; companyId (when resolvable) just
// enriches lanes they hold a contract on so "Book now" can target it.
const companyId = await this.billingService.resolveCompanyId( const companyId = await this.billingService.resolveCompanyId(
resolveAuthUserId(user), resolveAuthUserId(user),
); );
if (!companyId) return [];
return this.trainSchedulingService.getBookingWindowsForCompany(companyId); return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
} }

View File

@@ -3121,14 +3121,20 @@ export class TrainSchedulingService {
} }
/** /**
* Upcoming/open booking windows for a customer's active-contract lanes — * Upcoming/open booking windows announced on the portal home "booking
* powers the portal home "booking windows" section. Only window-engine * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
* schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are * are listed so every customer sees what is opening — not just those on their
* always open and need no announcement. * contract lanes; DOMESTIC trains are always open and need no announcement.
*
* When `companyId` is given, a matching active contract on the lane is
* LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling
* "Book now"); customers with no covering contract still see the window with a
* null contract, and the portal routes them to the contract list to get one.
*/ */
async getBookingWindowsForCompany(companyId: string) { async getBookingWindowsForCompany(companyId: string | null) {
const rows: Array<BookingWindowRow> = await this.dataSource.query( const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id, `SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
cr.contract_id AS contract_id, cr.contract_id AS contract_id,
c.contract_kind AS contract_kind, c.contract_kind AS contract_kind,
ts.direction, ts.direction,
@@ -3143,11 +3149,11 @@ export class TrainSchedulingService {
oy.label AS origin_label, oy.code AS origin_code, oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code dy.label AS destination_label, dy.code AS destination_code
FROM freight.train_schedules ts FROM freight.train_schedules ts
JOIN freight.contract_routes cr LEFT JOIN freight.contract_routes cr
ON cr.origin_yard_id = ts.origin_station_id ON cr.origin_yard_id = ts.origin_station_id
AND cr.destination_yard_id = ts.destination_station_id AND cr.destination_yard_id = ts.destination_station_id
AND cr.deleted_at IS NULL AND cr.deleted_at IS NULL
JOIN freight.contracts c LEFT JOIN freight.contracts c
ON c.id = cr.contract_id ON c.id = cr.contract_id
AND c.company_id = $1 AND c.company_id = $1
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
@@ -3159,10 +3165,16 @@ export class TrainSchedulingService {
AND ts.window_phase IS NOT NULL AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now() AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`, ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`,
[companyId], [companyId],
); );
return rows.map((r) => this.mapBookingWindowRow(r)); return rows
.map((r) => this.mapBookingWindowRow(r))
.sort((a, b) => {
const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return ta - tb;
});
} }
/** /**

View File

@@ -0,0 +1,250 @@
import { useMemo } from "react";
import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, CalendarClock } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { api } from "@/services/api";
import type { BatchBoardSchedule } from "@/types/trainScheduling";
/** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
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: BatchBoardSchedule): 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 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.
*/
function phaseCountdown(
w: BatchBoardSchedule,
): { 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 window ends in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
}
: null;
default:
return null;
}
}
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 {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.scheduleDate ? new Date(w.scheduleDate).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;
if (closes != null && closes <= now) return true;
return false;
}
/**
* 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.
*/
export function GlUpcomingWindowsSection() {
const { data, isLoading } = useQuery(
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }),
);
const windows = useMemo(() => {
const rows = (data ?? []).filter(
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
);
// Open lanes first, then by opening time.
return rows.sort((a, b) => {
const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a));
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;
return at - bt;
});
}, [data]);
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>
{isLoading ? (
<Stack gap={8}>
{[1, 2].map((i) => (
<Skeleton key={i} height={58} radius="md" />
))}
</Stack>
) : (
<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>
)}
</Card>
);
}

View File

@@ -50,6 +50,7 @@ import {
useEtClearanceQueue, useEtClearanceQueue,
} from "@/hooks/contracts/useContracts"; } from "@/hooks/contracts/useContracts";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
type ViewMode = "table" | "cards"; type ViewMode = "table" | "cards";
type QueueTab = "all" | "et"; type QueueTab = "all" | "et";
@@ -446,6 +447,8 @@ export default function ContractClearanceListPage() {
]} ]}
/> />
<GlUpcomingWindowsSection />
<Card p={0} withBorder shadow="sm" radius="lg"> <Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}> <Stack gap={0}>
{queueTabOptions.length > 1 ? ( {queueTabOptions.length > 1 ? (

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
Accordion, Accordion,
ActionIcon,
Alert, Alert,
Badge, Badge,
Box, Box,
@@ -24,9 +23,7 @@ import {
Boxes, Boxes,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
ChevronLeft,
ClipboardCheck, ClipboardCheck,
ChevronRight,
Clock, Clock,
FileSignature, FileSignature,
Hourglass, Hourglass,
@@ -40,7 +37,7 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common"; import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common";
import { KpiStrip, PageContainer } from "@/components/page"; import { KpiStrip, PageContainer } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -431,101 +428,148 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
} }
/** "05 Jun 2026 · 06:00 09:00 EAT" → "06:00 09:00 EAT" (date lives in the day header). */ /** "05 Jun 2026 · 06:00 09:00 EAT" → "06:00 09:00 EAT" (date lives in the day header). */
function timeLabelOf(label: string): string {
const idx = label.indexOf("·");
return idx >= 0 ? label.slice(idx + 1).trim() : label;
}
const EAT_TZ = "Africa/Addis_Ababa"; const EAT_TZ = "Africa/Addis_Ababa";
const dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
timeZone: EAT_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", { const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ, timeZone: EAT_TZ,
weekday: "short", weekday: "short",
day: "2-digit", day: "2-digit",
month: "short", month: "short",
}); });
const timeFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ interface ScheduleWindow {
function windowDateKey(w: BatchWindowGroup): string { windowPhase: BatchBoardScheduleDetail["windowPhase"];
if (w.date) return w.date; bookingWindowStatus: string;
if (w.start) return dateKeyFmt.format(new Date(w.start)); windowOpensAt: string | null;
return "undated"; windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo?: number;
} }
/** Human day label for a window — prefers the API field, falls back to `start`. */ /**
function windowDateLabel(w: BatchWindowGroup): string { * Deadline + label for the phase the schedule's booking window is currently in —
if (w.dateLabel) return w.dateLabel; * the SAME phases the customer sees on the portal: pre-window (opens) → open
if (w.start) return dateLabelFmt.format(new Date(w.start)); * (closes) → document review → payment. `expiredText` names the next step so a
return "Undated"; * lapsed deadline reads as a handover, not a bare "Expired".
*/
function windowPhaseCountdown(
w: ScheduleWindow,
): { 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 window ends in", deadline: w.paymentPhaseEndsAt, expiredText: "Payment window closing…" }
: null;
default:
return null;
}
} }
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { /** One phase row: label + its clock time (or "—" when unset). */
const total = window.bookings.length; function PhaseTimeRow({
const hasIssues = window.bookings.some( label,
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED", iso,
active,
}: {
label: string;
iso: string | null;
active: boolean;
}) {
return (
<Group justify="space-between" gap="sm" wrap="nowrap">
<Text size="sm" fw={active ? 700 : 500} c={active ? "edr-green.7" : "dimmed"}>
{label}
</Text>
<Text size="sm" fw={active ? 700 : 500} c={active ? "dark" : "dimmed"}>
{iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"}
</Text>
</Group>
); );
}
/**
* The schedule's REAL booking window — the exact same window the customer sees on
* the portal (frozen open/close from the schedule's own snapshot + the post-close
* document-review and payment phases), with a live countdown to the current phase.
* Replaces the old theoretical "3-hour windows across every day" projection.
*/
function ScheduleWindowPanel({ window: w }: { window: ScheduleWindow }) {
const phase = w.windowPhase;
const cd = windowPhaseCountdown(w);
const open = phase === "OPEN" && w.bookingWindowStatus === "OPEN";
const openDay = w.windowOpensAt
? dateLabelFmt.format(new Date(w.windowOpensAt))
: null;
return ( return (
<Accordion.Item value={window.key}> <Paper
<Accordion.Control> withBorder
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm"> radius="lg"
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> p="md"
<Box mt="md"
style={{ style={{
display: "flex", borderColor: open
alignItems: "center", ? "var(--mantine-color-edr-green-2)"
justifyContent: "center", : "var(--mantine-color-gray-2)",
width: 34, background: open ? "var(--mantine-color-edr-green-0)" : undefined,
height: 34, }}
borderRadius: 10, >
flexShrink: 0, <Group justify="space-between" wrap="nowrap" mb="sm">
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)", <Group gap="sm" wrap="nowrap">
border: total {phase ? (
? "1px solid #FBD171" <WindowPhasePill phase={phase} cycleNo={w.bookingCycleNo} />
: "1px solid var(--mantine-color-gray-2)", ) : null}
color: total ? "#B26C09" : "var(--mantine-color-gray-5)", <WindowStatusPill status={w.bookingWindowStatus} />
}}
>
<Clock size={16} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" truncate>
{timeLabelOf(window.label)}
</Text>
<Text size="xs" c="dimmed">
{total
? `${total} booking${total === 1 ? "" : "s"}`
: "Empty window"}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={window.counts} />
</Group>
</Group> </Group>
</Accordion.Control> {openDay ? (
<Accordion.Panel> <Text size="xs" c="dimmed">
<BookingTable bookings={window.bookings} /> Booking day · {openDay}
</Accordion.Panel> </Text>
</Accordion.Item> ) : null}
</Group>
{cd ? (
<Box mb="sm">
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="md"
/>
</Box>
) : null}
<Stack gap={6}>
<PhaseTimeRow label="Window opens" iso={w.windowOpensAt} active={phase === "PRE_WINDOW"} />
<PhaseTimeRow label="Window closes" iso={w.windowClosesAt} active={phase === "OPEN"} />
<PhaseTimeRow label="Document review ends" iso={w.docReviewEndsAt} active={phase === "DOC_REVIEW"} />
<PhaseTimeRow label="Payment window ends" iso={w.paymentPhaseEndsAt} active={phase === "PAYMENT"} />
</Stack>
</Paper>
); );
} }
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
export default function BatchScheduleDetailPage() { export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>(); const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -577,6 +621,34 @@ export default function BatchScheduleDetailPage() {
return [...byId.values()]; return [...byId.values()];
}, [data]); }, [data]);
// All bookings that fall inside the schedule's booking window (every window
// cycle, flattened) — the window is one booking day, so these belong to the
// single window panel above.
const windowBookings = useMemo(
() => (data?.windows ?? []).flatMap((w) => w.bookings),
[data?.windows],
);
const windowCounts = useMemo(() => {
const counts = {
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
};
for (const b of windowBookings) {
if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === "READY") counts.ready += 1;
else if (b.state === "WAITING") counts.waiting += 1;
else if (b.state === "EXPIRED") counts.expired += 1;
else counts.pendingContract += 1;
}
return counts;
}, [windowBookings]);
// Batch bookings by state for the composition side panel (payment / expired lists). // Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => { const batchBookings = useMemo(() => {
const all = allBookings; const all = allBookings;
@@ -591,102 +663,10 @@ export default function BatchScheduleDetailPage() {
[data?.status], [data?.status],
); );
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
if (!data) return [];
const byDate = new Map<
string,
{
date: string;
dateLabel: string;
windows: BatchWindowGroup[];
totalBookings: number;
counts: BatchWindowGroup["counts"];
hasIssues: boolean;
}
>();
for (const w of data.windows) {
const dateKey = windowDateKey(w);
let group = byDate.get(dateKey);
if (!group) {
group = {
date: dateKey,
dateLabel: windowDateLabel(w),
windows: [],
totalBookings: 0,
counts: {
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
},
hasIssues: false,
};
byDate.set(dateKey, group);
}
group.windows.push(w);
group.totalBookings += w.bookings.length;
group.counts.allocated += w.counts.allocated;
group.counts.selectedForBatch += w.counts.selectedForBatch;
group.counts.ready += w.counts.ready;
group.counts.waiting += w.counts.waiting;
group.counts.expired += w.counts.expired;
group.counts.pendingContract += w.counts.pendingContract;
group.hasIssues =
group.hasIssues ||
w.bookings.some(
(b) =>
b.allocationStatus === "FAILED" ||
b.allocationStatus === "DEFERRED",
);
}
return [...byDate.values()];
}, [data]);
// Windows with bookings open by default (inside an expanded day).
const openWindowKeys = useMemo(
() =>
data
? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key)
: [],
[data],
);
const todayEat = useMemo(
() =>
new Intl.DateTimeFormat("en-CA", {
timeZone: "Africa/Addis_Ababa",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date()),
[],
);
// Date-stepper: which day is currently shown. Default to today, else the first
// day with bookings, else the first day. Keep the selection if still valid.
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string | null>("overview"); const [activeTab, setActiveTab] = useState<string | null>("overview");
const [selectedBookingId, setSelectedBookingId] = useState<string | null>( const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
null, null,
); );
useEffect(() => {
if (!dayGroups.length) return;
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
const preferred =
dayGroups.find((d) => d.date === todayEat) ??
dayGroups.find((d) => d.totalBookings > 0) ??
dayGroups[0];
setSelectedDate(preferred.date);
}, [dayGroups, selectedDate, todayEat]);
const selectedIndex = Math.max(
0,
dayGroups.findIndex((d) => d.date === selectedDate),
);
const selectedDay = dayGroups[selectedIndex];
const handleCompleteDocReview = () => { const handleCompleteDocReview = () => {
completeDocReview completeDocReview
@@ -1012,137 +992,30 @@ export default function BatchScheduleDetailPage() {
<Clock size={19} /> <Clock size={19} />
</ThemeIcon> </ThemeIcon>
<Box> <Box>
<Title order={4}>Batch windows (EAT)</Title> <Title order={4}>Booking window (EAT)</Title>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
3-hour windows for every day from when the booking window The schedule's real booking window the same window and
opened through the departure date. Bookings appear under the phase timings the customer sees on the portal. Bookings in
date their contract was signed open a day to see its the window are listed below.
windows.
</Text> </Text>
</Box> </Box>
</Group> </Group>
{dayGroups.length && selectedDay ? ( <ScheduleWindowPanel window={data} />
<>
{/* Date stepper — page back/forward through each day in the range */}
<Group
justify="center"
align="center"
wrap="nowrap"
gap="md"
mt="md"
>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Previous day"
disabled={selectedIndex <= 0}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex - 1]?.date ?? null,
)
}
>
<ChevronLeft size={20} />
</ActionIcon>
<Paper {windowBookings.length ? (
withBorder <Box mt="lg">
radius="xl" <Group justify="space-between" align="center" mb="sm">
px="xl" <Text fw={700} size="sm">
py="xs" Bookings in this window
style={{
flex: 1,
maxWidth: 360,
textAlign: "center",
background: selectedDay.totalBookings
? "#FEF1D5"
: "white",
borderColor: selectedDay.totalBookings
? "#FBD171"
: "var(--mantine-color-gray-2)",
}}
>
<Group justify="center" gap={8} wrap="nowrap">
<CalendarDays size={15} color="#B26C09" />
<Text
fw={800}
style={{
color: selectedDay.totalBookings
? "#8A5304"
: "#0f172a",
}}
>
{selectedDay.dateLabel}
</Text>
{selectedDay.date === todayEat ? (
<Badge size="xs" variant="light" color="#F2A516">
Today
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" mt={2}>
{selectedDay.totalBookings
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
: `${selectedDay.windows.length} windows · no bookings`}
</Text>
</Paper>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Next day"
disabled={selectedIndex >= dayGroups.length - 1}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex + 1]?.date ?? null,
)
}
>
<ChevronRight size={20} />
</ActionIcon>
</Group>
<Group justify="space-between" align="center" mt="sm">
<Text size="xs" c="dimmed">
Day {selectedIndex + 1} of {dayGroups.length}
</Text> </Text>
<Group gap={6} wrap="nowrap"> <WindowCountChips counts={windowCounts} />
{selectedDay.hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={selectedDay.counts} />
</Group>
</Group> </Group>
<BookingTable bookings={windowBookings} />
<Accordion </Box>
key={selectedDay.date}
multiple
defaultValue={openWindowKeys}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
{selectedDay.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
</Accordion>
</>
) : ( ) : (
<Text size="sm" c="dimmed" ta="center" py="lg"> <Text size="sm" c="dimmed" ta="center" py="lg">
No batch windows for this schedule. No bookings in this window yet.
</Text> </Text>
)} )}

View File

@@ -45,22 +45,50 @@ function windowLabel(w: MyBookingWindow): string {
} }
/** /**
* The deadline + label for whichever phase the window is currently in. Phases * The countdown for whichever phase the window is currently in. Phases run:
* run: window open (closes at windowClosesAt) → document review (docReviewEndsAt) * pre-window (opens at windowOpensAt) → open (closes at windowClosesAt)
* → payment (paymentPhaseEndsAt). Returns null when no phase is timing down. * document review (docReviewEndsAt) → payment (paymentPhaseEndsAt).
*
* `label` describes the deadline being counted down to; `expiredText` names the
* NEXT step so that when a deadline lapses between the 60s refetches the row
* announces what comes next ("Booking opening now…", "Review starting…") rather
* than the bare word "Expired". Returns null when no phase is timing down.
*/ */
function phaseCountdown( function phaseCountdown(
w: MyBookingWindow, w: MyBookingWindow,
): { label: string; deadline: string } | null { ): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) { switch (w.windowPhase) {
case "PRE_WINDOW":
if (w.windowOpensAt)
return {
label: "Booking opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
};
return null;
case "OPEN": case "OPEN":
if (w.windowClosesAt) return { label: "Window closes in", deadline: w.windowClosesAt }; if (w.windowClosesAt)
return {
label: "Window closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
};
return null; return null;
case "DOC_REVIEW": case "DOC_REVIEW":
if (w.docReviewEndsAt) return { label: "Document review ends in", deadline: w.docReviewEndsAt }; if (w.docReviewEndsAt)
return {
label: "Document review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
};
return null; return null;
case "PAYMENT": case "PAYMENT":
if (w.paymentPhaseEndsAt) return { label: "Payment due in", deadline: w.paymentPhaseEndsAt }; if (w.paymentPhaseEndsAt)
return {
label: "Payment due in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
};
return null; return null;
default: default:
return null; return null;
@@ -141,9 +169,11 @@ interface UpcomingWindowsSectionProps {
} }
/** /**
* The customer's upcoming/open booking windows on their active-contract * All announced upcoming/open booking windows, shown to every customer
* lanes. Import trains open a window on one booking day; export trains open * regardless of whether they hold a contract on the lane. Import trains open a
* 24h before departure. Hidden entirely when there is nothing to show. * window on one booking day; export trains open 24h before departure. Rows on a
* lane the customer has an active contract for carry a "Book now" action;
* others route to the contract list. Hidden entirely when nothing is announced.
*/ */
export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
windows, windows,
@@ -162,7 +192,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
Booking Windows Booking Windows
</Text> </Text>
<Text fz={13} c="edr-muted"> <Text fz={13} c="edr-muted">
Upcoming and open booking windows on your contract lanes Upcoming and open booking windows across all lanes
</Text> </Text>
</Box> </Box>
</Group> </Group>
@@ -211,6 +241,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
<CountdownTimer <CountdownTimer
deadline={cd.deadline} deadline={cd.deadline}
label={cd.label} label={cd.label}
expiredText={cd.expiredText}
size="xs" size="xs"
/> />
</Box> </Box>

View File

@@ -47,13 +47,16 @@ export interface PriceLineItem {
} }
/** /**
* An upcoming/open booking window on one of the signed-in customer's * An announced upcoming/open booking window, shown to every signed-in customer
* active-contract lanes. Import trains open a window on one booking day; * regardless of contract. Import trains open a window on one booking day;
* export trains open 24h before departure (first come, first served). * export trains open 24h before departure (first come, first served).
*/ */
export interface MyBookingWindow { export interface MyBookingWindow {
scheduleId: string; scheduleId: string;
/** Contract whose route this window belongs to, when the row carries it. */ /**
* The customer's active contract on this lane, when they hold one — enables
* "Book now" to target it. Null for lanes they have no contract on.
*/
contractId: string | null; contractId: string | null;
/** ONE_TIME contracts can't draw down against a window — button is hidden. */ /** ONE_TIME contracts can't draw down against a window — button is hidden. */
contractKind: "ONE_TIME" | "GENERAL" | null; contractKind: "ONE_TIME" | "GENERAL" | null;