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. )}