diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
index 7e38ba932..de6ecfa9b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
@@ -1,8 +1,7 @@
-import { useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
- ActionIcon,
Alert,
Badge,
Box,
@@ -24,9 +23,7 @@ import {
Boxes,
CalendarDays,
CheckCircle2,
- ChevronLeft,
ClipboardCheck,
- ChevronRight,
Clock,
FileSignature,
Hourglass,
@@ -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). */
-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 dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
- timeZone: EAT_TZ,
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
-});
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
weekday: "short",
day: "2-digit",
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`. */
-function windowDateKey(w: BatchWindowGroup): string {
- if (w.date) return w.date;
- if (w.start) return dateKeyFmt.format(new Date(w.start));
- return "undated";
+interface ScheduleWindow {
+ windowPhase: BatchBoardScheduleDetail["windowPhase"];
+ bookingWindowStatus: string;
+ windowOpensAt: string | null;
+ 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 {
- if (w.dateLabel) return w.dateLabel;
- if (w.start) return dateLabelFmt.format(new Date(w.start));
- return "Undated";
+/**
+ * Deadline + label for the phase the schedule's booking window is currently in —
+ * the SAME phases the customer sees on the portal: pre-window (opens) → open
+ * (closes) → document review → payment. `expiredText` names the next step so a
+ * 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 }) {
- const total = window.bookings.length;
- const hasIssues = window.bookings.some(
- (b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
+/** One phase row: label + its clock time (or "—" when unset). */
+function PhaseTimeRow({
+ label,
+ iso,
+ active,
+}: {
+ label: string;
+ iso: string | null;
+ active: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"}
+
+
);
+}
+
+/**
+ * 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 (
-
-
-
-
-
-
-
-
-
- {timeLabelOf(window.label)}
-
-
- {total
- ? `${total} booking${total === 1 ? "" : "s"}`
- : "Empty window"}
-
-
-
-
- {hasIssues ? (
- }
- >
- Issues
-
- ) : null}
-
-
+
+
+
+ {phase ? (
+
+ ) : null}
+
-
-
-
-
-
+ {openDay ? (
+
+ Booking day · {openDay}
+
+ ) : null}
+
+
+ {cd ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
);
}
+/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
+
export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate();
@@ -577,6 +621,34 @@ export default function BatchScheduleDetailPage() {
return [...byId.values()];
}, [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).
const batchBookings = useMemo(() => {
const all = allBookings;
@@ -591,102 +663,10 @@ export default function BatchScheduleDetailPage() {
[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(null);
const [activeTab, setActiveTab] = useState("overview");
const [selectedBookingId, setSelectedBookingId] = useState(
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 = () => {
completeDocReview
@@ -1012,137 +992,30 @@ export default function BatchScheduleDetailPage() {
- Batch windows (EAT)
+ Booking window (EAT)
- 3-hour windows for every day from when the booking window
- opened through the departure date. Bookings appear under the
- date their contract was signed — open a day to see its
- windows.
+ The schedule's real booking window — the same window and
+ phase timings the customer sees on the portal. Bookings in
+ the window are listed below.
- {dayGroups.length && selectedDay ? (
- <>
- {/* Date stepper — page back/forward through each day in the range */}
-
-
- setSelectedDate(
- dayGroups[selectedIndex - 1]?.date ?? null,
- )
- }
- >
-
-
+
-
-
-
-
- {selectedDay.dateLabel}
-
- {selectedDay.date === todayEat ? (
-
- Today
-
- ) : null}
-
-
- {selectedDay.totalBookings
- ? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
- : `${selectedDay.windows.length} windows · no bookings`}
-
-
-
- = dayGroups.length - 1}
- onClick={() =>
- setSelectedDate(
- dayGroups[selectedIndex + 1]?.date ?? null,
- )
- }
- >
-
-
-
-
-
-
- Day {selectedIndex + 1} of {dayGroups.length}
+ {windowBookings.length ? (
+
+
+
+ Bookings in this window
-
- {selectedDay.hasIssues ? (
- }
- >
- Issues
-
- ) : null}
-
-
+
-
-
- {selectedDay.windows.map((window) => (
-
- ))}
-
- >
+
+
) : (
- No batch windows for this schedule.
+ No bookings in this window yet.
)}