diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 45370f546..b7b907e5b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -31,6 +31,19 @@ export function paymentDrainMs(): number { ); } +/** + * ISO timestamp of the end of a pay window's drain tail, for client display + * (the "payment processing" countdown). Null in ⇒ null out. + */ +export function paymentDrainEndsAtIso( + deadline: Date | string | null | undefined, +): string | null { + if (deadline == null) return null; + const ms = new Date(deadline).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms + paymentDrainMs()).toISOString(); +} + /** * A pay window AND its drain tail have closed. * diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts index 69e60dd3a..dbf0cae96 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { paymentDrainEndsAtIso } from './booking-batch.constants'; /** * Server → client push for booking-window state changes. Same handshake model @@ -61,6 +62,7 @@ export class BookingWindowGateway implements OnGatewayConnection { windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null, docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null, paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + paymentDrainEndsAt: paymentDrainEndsAtIso(schedule.paymentPhaseEndsAt), scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, }; this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts index 6054f28b2..bde0e88ec 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts @@ -1,5 +1,6 @@ import { DEFAULT_PAYMENT_DRAIN_MINUTES, + paymentDrainEndsAtIso, paymentDrainMs, payWindowLapsed, } from "./booking-batch.constants"; @@ -64,4 +65,16 @@ describe("payWindowLapsed — pay-window drain tail", () => { expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN); } }); + + it("paymentDrainEndsAtIso reports deadline + drain, null/garbage-safe", () => { + expect(paymentDrainEndsAtIso(deadline)).toBe( + new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(), + ); + expect(paymentDrainEndsAtIso(deadline.toISOString())).toBe( + new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(), + ); + expect(paymentDrainEndsAtIso(null)).toBeNull(); + expect(paymentDrainEndsAtIso(undefined)).toBeNull(); + expect(paymentDrainEndsAtIso("not-a-date")).toBeNull(); + }); }); 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 e8ed6ff4b..9a03f665c 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 @@ -153,6 +153,7 @@ import { DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, + paymentDrainEndsAtIso, } from './booking-batch.constants'; import { orderConsistWagons } from './consist-order.util'; import { @@ -6940,6 +6941,7 @@ export class TrainSchedulingService { windowClosesAt: r.window_closes_at, docReviewEndsAt: r.doc_review_ends_at, paymentPhaseEndsAt: r.payment_phase_ends_at, + paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at), bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index ba9e01402..df1a66330 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -39,6 +39,8 @@ interface WindowRow { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + /** End of the payment drain tail — pending payments may settle until then. */ + paymentDrainEndsAt?: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; @@ -94,16 +96,29 @@ const COUNTDOWN_TEXT: Partial< PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" }, OPEN: { label: "Closes in", expiredText: "Review starting…" }, DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" }, - PAYMENT: { label: "Payment ends in", expiredText: "Closing…" }, + PAYMENT: { label: "Payment ends in", expiredText: "Finalizing…" }, }; -function phaseCountdown( - w: WindowRow, -): { label: string; deadline: string; expiredText: string } | null { +function phaseCountdown(w: WindowRow): { + label: string; + deadline: string; + expiredText: string; + graceDeadline?: string | null; + graceLabel?: string; +} | null { const state = bookingWindowUiState(w); const text = COUNTDOWN_TEXT[state.kind]; if (!state.countdownTo || !text) return null; - return { ...text, deadline: state.countdownTo }; + // Once the pay deadline lapses, pending payments still settle during the + // drain tail — count it down as "processing" instead of a stale "closing". + const grace = + state.kind === "PAYMENT" && w.paymentDrainEndsAt + ? { + graceDeadline: w.paymentDrainEndsAt, + graceLabel: "Processing payments — closes in", + } + : undefined; + return { ...text, deadline: state.countdownTo, ...grace }; } /** Badge label + Mantine color per UI state — same state the countdown uses. */ @@ -225,6 +240,8 @@ function WindowCard({ w }: { w: WindowRow }) { deadline={cd.deadline} label={cd.label} expiredText={cd.expiredText} + graceDeadline={cd.graceDeadline} + graceLabel={cd.graceLabel} size="xs" /> diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index b9195b1ff..59318f703 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -30,6 +30,7 @@ interface WindowRow { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + paymentDrainEndsAt?: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; @@ -55,6 +56,7 @@ function applyEvent(row: T, event: BookingWindowPhaseEvent) windowClosesAt: event.windowClosesAt, docReviewEndsAt: event.docReviewEndsAt, paymentPhaseEndsAt: event.paymentPhaseEndsAt, + paymentDrainEndsAt: event.paymentDrainEndsAt, departureDate: event.scheduledDepartureDate ?? row.departureDate, }; } diff --git a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts index 6df836357..7c33f5849 100644 --- a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -47,6 +47,7 @@ function applyEvent( windowClosesAt: event.windowClosesAt, docReviewEndsAt: event.docReviewEndsAt, paymentPhaseEndsAt: event.paymentPhaseEndsAt, + paymentDrainEndsAt: event.paymentDrainEndsAt, departureDate: event.scheduledDepartureDate ?? row.departureDate, }; } 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 50e142708..c4bb03ed6 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 @@ -68,16 +68,29 @@ const COUNTDOWN_TEXT: Partial< PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" }, OPEN: { label: "Window closes in", expiredText: "Document review starting…" }, DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" }, - PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" }, + PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" }, }; -function phaseCountdown( - w: MyBookingWindow, -): { label: string; deadline: string; expiredText: string } | null { +function phaseCountdown(w: MyBookingWindow): { + label: string; + deadline: string; + expiredText: string; + graceDeadline?: string | null; + graceLabel?: string; +} | null { const state = bookingWindowUiState(w); const text = COUNTDOWN_TEXT[state.kind]; if (!state.countdownTo || !text) return null; - return { ...text, deadline: state.countdownTo }; + // Once the pay deadline lapses, pending payments still settle during the + // drain tail — count it down as "processing" instead of a stale "closing". + const grace = + state.kind === "PAYMENT" && w.paymentDrainEndsAt + ? { + graceDeadline: w.paymentDrainEndsAt, + graceLabel: "Payment processing — closes in", + } + : undefined; + return { ...text, deadline: state.countdownTo, ...grace }; } function Pill({ @@ -336,6 +349,8 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ deadline={cd.deadline} label={cd.label} expiredText={cd.expiredText} + graceDeadline={cd.graceDeadline} + graceLabel={cd.graceLabel} size="xs" /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx index a8d9d0830..c4fa72977 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -104,16 +104,29 @@ const COUNTDOWN_TEXT: Partial< PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" }, OPEN: { label: "Window closes in", expiredText: "Document review starting…" }, DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" }, - PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" }, + PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" }, }; -function phaseCountdown( - w: MyBookingWindow, -): { label: string; deadline: string; expiredText: string } | null { +function phaseCountdown(w: MyBookingWindow): { + label: string; + deadline: string; + expiredText: string; + graceDeadline?: string | null; + graceLabel?: string; +} | null { const state = bookingWindowUiState(w); const text = COUNTDOWN_TEXT[state.kind]; if (!state.countdownTo || !text) return null; - return { ...text, deadline: state.countdownTo }; + // Once the pay deadline lapses, pending payments still settle during the + // drain tail — count it down as "processing" instead of a stale "closing". + const grace = + state.kind === "PAYMENT" && w.paymentDrainEndsAt + ? { + graceDeadline: w.paymentDrainEndsAt, + graceLabel: "Payment processing — closes in", + } + : undefined; + return { ...text, deadline: state.countdownTo, ...grace }; } /** @@ -226,6 +239,8 @@ function WindowCard({ w }: { w: MyBookingWindow }) { deadline={cd.deadline} label={cd.label} expiredText={cd.expiredText} + graceDeadline={cd.graceDeadline} + graceLabel={cd.graceLabel} size="xs" /> 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 4aba2c734..dd00a0045 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -91,6 +91,8 @@ export interface MyBookingWindow { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + /** End of the payment drain tail — pending payments may settle until then. */ + paymentDrainEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; diff --git a/packages/types/src/freight/booking-window-ws.ts b/packages/types/src/freight/booking-window-ws.ts index 3fd3c4d7b..be0dc9c13 100644 --- a/packages/types/src/freight/booking-window-ws.ts +++ b/packages/types/src/freight/booking-window-ws.ts @@ -29,6 +29,11 @@ export interface BookingWindowPhaseEvent { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + /** + * When the payment drain tail ends (paymentPhaseEndsAt + server drain + * minutes) — pending payments may still settle until then. Display only. + */ + paymentDrainEndsAt: string | null; scheduledDepartureDate: string | null; } diff --git a/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx index faadb0adf..1a906d96e 100644 --- a/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx +++ b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx @@ -1,4 +1,4 @@ -import { Group, Text } from "@mantine/core"; +import { Group, Loader, Text } from "@mantine/core"; import { Clock } from "lucide-react"; import { useEffect, useState } from "react"; @@ -9,6 +9,14 @@ export interface CountdownTimerProps { label?: string; /** Text shown once the deadline has passed. */ expiredText?: string; + /** + * Optional grace stage: once `deadline` passes, count down to this later + * ISO timestamp instead (e.g. the payment drain tail). `expiredText` then + * only shows after the grace deadline has also passed. + */ + graceDeadline?: string | null; + /** Label shown while counting down the grace stage (e.g. "Payment processing — closes in"). */ + graceLabel?: string; /** Visual size of the time text. */ size?: "xs" | "sm" | "md" | "lg"; /** Colour once under this many seconds remain (urgency). Default 300 (5 min). */ @@ -35,37 +43,56 @@ function formatRemaining(ms: number): string { /** * Live countdown to an ISO deadline. Ticks once a second, shows the remaining * time (d/h/m/s), turns red when under `urgentUnderSeconds`, and shows - * `expiredText` once the deadline is in the past. Display only — enforcement - * lives server-side. + * `expiredText` once the deadline is in the past. When `graceDeadline` is set, + * a lapsed main deadline rolls into a calm second-stage countdown (spinner, + * no urgency colours) toward it before `expiredText` takes over. Display only + * — enforcement lives server-side. */ export function CountdownTimer({ deadline, label, expiredText = "Expired", + graceDeadline, + graceLabel, size = "sm", urgentUnderSeconds = 300, }: CountdownTimerProps) { - const [remaining, setRemaining] = useState(() => - deadline ? new Date(deadline).getTime() - Date.now() : null, - ); + const [now, setNow] = useState(() => Date.now()); useEffect(() => { - if (!deadline) { - setRemaining(null); - return; - } - const target = new Date(deadline).getTime(); - const tick = () => setRemaining(target - Date.now()); - tick(); - const id = setInterval(tick, 1000); + if (!deadline) return; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); - }, [deadline]); + }, [deadline, graceDeadline]); - if (!deadline || remaining == null || Number.isNaN(remaining)) { - return null; + if (!deadline) return null; + const target = new Date(deadline).getTime(); + if (Number.isNaN(target)) return null; + + const remaining = target - now; + const expired = remaining <= 0; + + const graceTarget = graceDeadline ? new Date(graceDeadline).getTime() : NaN; + const graceRemaining = Number.isNaN(graceTarget) ? 0 : graceTarget - now; + const inGrace = expired && graceRemaining > 0; + + if (inGrace) { + return ( + + + {graceLabel && ( + + {graceLabel} + + )} + + {formatRemaining(graceRemaining)} + + + ); } - const expired = remaining <= 0; const urgent = !expired && remaining <= urgentUnderSeconds * 1000; const color = expired ? "red.7" : urgent ? "orange.7" : "dimmed";