From c2649dae3c131fdd88fd610a49711fd9a2be0072 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:11:04 +0000 Subject: [PATCH 1/3] enhance phase countdown and booking window descriptions for clarity --- .../components/UpcomingWindowsSection.tsx | 53 +++++++++++++++---- .../portal/src/services/bookings.service.ts | 9 ++-- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index bb5b629de..300ff643f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -45,22 +45,50 @@ function windowLabel(w: MyBookingWindow): string { } /** - * The deadline + label for whichever phase the window is currently in. Phases - * run: window open (closes at windowClosesAt) → document review (docReviewEndsAt) - * → payment (paymentPhaseEndsAt). Returns null when no phase is timing down. + * The countdown for whichever phase the window is currently in. Phases run: + * pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) → + * 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( w: MyBookingWindow, -): { label: string; deadline: string } | null { +): { label: string; deadline: string; expiredText: string } | null { 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": - 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; 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; 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; default: return null; @@ -141,9 +169,11 @@ interface UpcomingWindowsSectionProps { } /** - * The customer's upcoming/open booking windows on their active-contract - * lanes. Import trains open a window on one booking day; export trains open - * 24h before departure. Hidden entirely when there is nothing to show. + * All announced upcoming/open booking windows, shown to every customer + * regardless of whether they hold a contract on the lane. Import trains open a + * 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({ windows, @@ -162,7 +192,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ Booking Windows - Upcoming and open booking windows on your contract lanes + Upcoming and open booking windows across all lanes @@ -211,6 +241,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 79676cab1..c2729bebb 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -47,13 +47,16 @@ export interface PriceLineItem { } /** - * An upcoming/open booking window on one of the signed-in customer's - * active-contract lanes. Import trains open a window on one booking day; + * An announced upcoming/open booking window, shown to every signed-in customer + * regardless of contract. Import trains open a window on one booking day; * export trains open 24h before departure (first come, first served). */ export interface MyBookingWindow { 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; /** ONE_TIME contracts can't draw down against a window — button is hidden. */ contractKind: "ONE_TIME" | "GENERAL" | null; From 69818ab3f996db2a5f87db7de0744eca9758ed42 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:30:49 +0000 Subject: [PATCH 2/3] add booking window to gl --- .../train-scheduling.controller.ts | 5 +- .../train-scheduling.service.ts | 32 ++- .../contracts/GlUpcomingWindowsSection.tsx | 250 ++++++++++++++++++ .../contracts/ContractClearanceListPage.tsx | 3 + .../BatchScheduleDetailPage.tsx | 2 +- 5 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 2d591f2e0..22e322953 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -61,13 +61,14 @@ export class TrainSchedulingController { @Get("my-booking-windows") @ApiOperation({ 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) { + // 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( resolveAuthUserId(user), ); - if (!companyId) return []; return this.trainSchedulingService.getBookingWindowsForCompany(companyId); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 199be696a..bac226c0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3121,14 +3121,20 @@ export class TrainSchedulingService { } /** - * Upcoming/open booking windows for a customer's active-contract lanes — - * powers the portal home "booking windows" section. Only window-engine - * schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are - * always open and need no announcement. + * Upcoming/open booking windows announced on the portal home "booking + * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead) + * are listed so every customer sees what is opening — not just those on their + * 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 = 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, c.contract_kind AS contract_kind, ts.direction, @@ -3143,11 +3149,11 @@ export class TrainSchedulingService { oy.label AS origin_label, oy.code AS origin_code, dy.label AS destination_label, dy.code AS destination_code 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 AND cr.destination_yard_id = ts.destination_station_id AND cr.deleted_at IS NULL - JOIN freight.contracts c + LEFT JOIN freight.contracts c ON c.id = cr.contract_id AND c.company_id = $1 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 NOT IN ('DONE', 'CLOSED_FOR_DAY') 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], ); - 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; + }); } /** diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx new file mode 100644 index 000000000..9d9573909 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -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 ( + + + + + + Booking windows + + + Upcoming and open import booking windows across all lanes (EAT) + + + + + {isLoading ? ( + + {[1, 2].map((i) => ( + + ))} + + ) : ( + + + {windows.map((w) => { + const open = isOpenNow(w); + const cd = phaseCountdown(w); + return ( + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + {w.trainNumber ? ( + + · {w.trainNumber} + + ) : null} + + + {windowLabel(w)} + {w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""} + + {cd ? ( + + + + ) : null} + + + + {w.direction ? ( + + {w.direction === "IMPORT" ? "Import" : "Export"} + + ) : null} + + {open + ? "Open now" + : w.windowPhase === "PRE_WINDOW" && w.windowOpensAt + ? `Opens ${fmtTime(w.windowOpensAt)} EAT` + : (w.windowPhase ?? w.bookingWindowStatus).replace( + /_/g, + " ", + )} + + + + ); + })} + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 034958303..1121329fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -50,6 +50,7 @@ import { useEtClearanceQueue, } from "@/hooks/contracts/useContracts"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection"; type ViewMode = "table" | "cards"; type QueueTab = "all" | "et"; @@ -446,6 +447,8 @@ export default function ContractClearanceListPage() { ]} /> + + {queueTabOptions.length > 1 ? ( 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 a4ebd531e..7e38ba932 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -40,7 +40,7 @@ import { XCircle, } 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 { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; From 61f70d54716a343ac8e444955528e0edf5bda9f8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:37:11 +0000 Subject: [PATCH 3/3] add booking window to gl --- .../BatchScheduleDetailPage.tsx | 459 +++++++----------- 1 file changed, 166 insertions(+), 293 deletions(-) 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. )}