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";