From 0ac5adcee9a2e537763b43b966571982809bb2fe Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 17:11:35 +0000 Subject: [PATCH] Add countdown timer component and integrate phase deadlines in booking windows --- .../train-scheduling.service.ts | 28 ++++++ .../ScheduleWorkspacePanel.tsx | 47 ++++++++++ .../backoffice/src/types/trainScheduling.ts | 5 ++ .../components/UpcomingWindowsSection.tsx | 36 ++++++++ .../BookingDetailPage/ReadonlyBookingView.tsx | 60 ++----------- .../components/ContractCard.tsx | 26 +----- .../portal/src/services/bookings.service.ts | 2 + .../CountdownTimer/CountdownTimer.tsx | 87 +++++++++++++++++++ .../src/components/CountdownTimer/index.ts | 2 + packages/ui-common/src/index.ts | 3 + 10 files changed, 217 insertions(+), 79 deletions(-) create mode 100644 packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx create mode 100644 packages/ui-common/src/components/CountdownTimer/index.ts 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 0aadc62ab..53ebc445b 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 @@ -178,6 +178,8 @@ interface BookingWindowRow { window_phase: string | null; window_opens_at: Date | null; window_closes_at: Date | null; + doc_review_ends_at: Date | null; + payment_phase_ends_at: Date | null; booking_window_status: string; booking_cycle_no: number; scheduled_departure_date: Date; @@ -3027,6 +3029,8 @@ export class TrainSchedulingService { ts.window_phase, ts.window_opens_at, ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, ts.booking_window_status, ts.booking_cycle_no, ts.scheduled_departure_date, @@ -3041,6 +3045,7 @@ export class TrainSchedulingService { ON c.id = cr.contract_id AND c.company_id = $1 AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.contract_kind = 'GENERAL' AND c.deleted_at IS NULL LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -3068,6 +3073,8 @@ export class TrainSchedulingService { ts.window_phase, ts.window_opens_at, ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, ts.booking_window_status, ts.booking_cycle_no, ts.scheduled_departure_date, @@ -3079,6 +3086,10 @@ export class TrainSchedulingService { AND cr.destination_yard_id = ts.destination_station_id AND cr.contract_id = $1 AND cr.deleted_at IS NULL + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.contract_kind = 'GENERAL' + AND c.deleted_at IS NULL LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id WHERE ts.deleted_at IS NULL @@ -3101,6 +3112,8 @@ export class TrainSchedulingService { isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', windowOpensAt: r.window_opens_at, windowClosesAt: r.window_closes_at, + docReviewEndsAt: r.doc_review_ends_at, + paymentPhaseEndsAt: r.payment_phase_ends_at, bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, @@ -3388,6 +3401,21 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? null, + // Booking-window phase + phase deadlines drive the countdown timers in the + // operations workspace (display only — the window engine enforces them). + windowPhase: schedule.windowPhase ?? null, + windowOpensAt: schedule.windowOpensAt + ? schedule.windowOpensAt.toISOString() + : null, + windowClosesAt: schedule.windowClosesAt + ? schedule.windowClosesAt.toISOString() + : null, + docReviewEndsAt: schedule.docReviewEndsAt + ? schedule.docReviewEndsAt.toISOString() + : null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt + ? schedule.paymentPhaseEndsAt.toISOString() + : null, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } : null, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 07222baa1..4c2ccb57e 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -28,6 +28,8 @@ import { X, } from "lucide-react"; +import { CountdownTimer } from "@edr/ui-common"; + import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; @@ -45,6 +47,32 @@ interface ScheduleWorkspacePanelProps { const GREEN = "var(--mantine-color-edr-green-6)"; +/** + * Deadline + label for the window phase this schedule is currently in. + * Phases run: window open (windowClosesAt) → document review (docReviewEndsAt) + * → payment (paymentPhaseEndsAt). Display only. Returns null off-phase. + */ +function phaseCountdown( + schedule: TrainScheduleDetail, +): { label: string; deadline: string } | null { + switch (schedule.windowPhase) { + case "OPEN": + return schedule.windowClosesAt + ? { label: "Booking window closes in", deadline: schedule.windowClosesAt } + : null; + case "DOC_REVIEW": + return schedule.docReviewEndsAt + ? { label: "Document review ends in", deadline: schedule.docReviewEndsAt } + : null; + case "PAYMENT": + return schedule.paymentPhaseEndsAt + ? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt } + : null; + default: + return null; + } +} + /** Cargo weight already allocated to this train (sum of on-train bookings). */ function usedWeight(schedule: TrainScheduleDetail): number { return (schedule.bookings ?? []).reduce( @@ -248,6 +276,25 @@ export function ScheduleWorkspacePanel({ + {(() => { + const cd = phaseCountdown(schedule); + return cd ? ( + + + + ) : null; + })()} + {over ? ( + {(() => { + const cd = phaseCountdown(w); + return cd ? ( + + + + ) : null; + })()} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index f0f75f537..6d274f22f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,12 +1,10 @@ -import { Box, Group, Text } from "@mantine/core"; +import { Group } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { CreditCard, Download, Eye } from "lucide-react"; +import { CreditCard } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; -import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import { invoicesService } from "@/services/invoices.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service"; @@ -19,9 +17,8 @@ import { ClearanceCard } from "./components/ClearanceCard"; import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard"; -import { DocRow, IconSquare } from "./components/Documents"; import { KeyFactsStrip } from "./components/KeyFactsStrip"; -import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; +import { BodyGrid, PageShell } from "./components/layout"; import { CancelledBanner, ConsolidationPairedNotice, @@ -51,7 +48,7 @@ export function ReadonlyBookingView({ useScrollToHash(); const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); - const { view, viewer } = useFileViewer(); + const { viewer } = useFileViewer(); // Re-book opens the New Shipment Booking form for the same contract, not the // New Contract page. Fall back to /contracts/new only if the link is missing. @@ -160,7 +157,6 @@ export function ReadonlyBookingView({ ) } menuActions={{ - onViewContract: booking.signedByCeoAt ? () => {} : undefined, onRebook, onSupport: () => navigate("/support"), }} @@ -199,7 +195,7 @@ export function ReadonlyBookingView({ - + {isClearance && } @@ -220,52 +216,6 @@ export function ReadonlyBookingView({ )} - {booking.files && booking.files.length > 0 && ( - - - Documents - - {booking.files.length} files - - - - {booking.files.map((file, i) => ( - - {isViewable({ - name: file.name, - url: fileViewUrl(file.id), - mimeType: file.mimeType, - }) && ( - } - onClick={() => - view({ - name: file.name, - url: fileViewUrl(file.id), - mimeType: file.mimeType, - }) - } - /> - )} - } - /> - - } - /> - ))} - - - )} - } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx index f56e07349..083e4de26 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx @@ -1,6 +1,4 @@ -import { Box, Button, Group, Paper, Text } from "@mantine/core"; -import { FileSignature } from "lucide-react"; -import type { useNavigate } from "react-router-dom"; +import { Box, Group, Paper, Text } from "@mantine/core"; import type { Freight } from "@edr/types"; @@ -37,13 +35,7 @@ const CONTRACT_CONFIG: Record< }, }; -export function ContractCard({ - booking, - navigate, -}: { - booking: Freight.IBooking; - navigate: ReturnType; -}) { +export function ContractCard({ booking }: { booking: Freight.IBooking }) { const c = CONTRACT_CONFIG[booking.status as string]; if (!c) return null; @@ -76,20 +68,6 @@ export function ContractCard({ {c.description} - {c.buttonLabel && ( - - )} ); 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 f92e20cf4..c6481bb27 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -60,6 +60,8 @@ export interface MyBookingWindow { isOpenNow: boolean; windowOpensAt: string | null; windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; diff --git a/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx new file mode 100644 index 000000000..faadb0adf --- /dev/null +++ b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx @@ -0,0 +1,87 @@ +import { Group, Text } from "@mantine/core"; +import { Clock } from "lucide-react"; +import { useEffect, useState } from "react"; + +export interface CountdownTimerProps { + /** ISO timestamp the countdown targets. */ + deadline: string | null | undefined; + /** Optional label shown before the time (e.g. "Window closes in"). */ + label?: string; + /** Text shown once the deadline has passed. */ + expiredText?: string; + /** Visual size of the time text. */ + size?: "xs" | "sm" | "md" | "lg"; + /** Colour once under this many seconds remain (urgency). Default 300 (5 min). */ + urgentUnderSeconds?: number; +} + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +/** Break a remaining-milliseconds figure into a human string. */ +function formatRemaining(ms: number): string { + const total = Math.floor(ms / 1000); + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + + if (days > 0) return `${days}d ${pad(hours)}h ${pad(minutes)}m`; + if (hours > 0) return `${hours}h ${pad(minutes)}m ${pad(seconds)}s`; + return `${pad(minutes)}m ${pad(seconds)}s`; +} + +/** + * 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. + */ +export function CountdownTimer({ + deadline, + label, + expiredText = "Expired", + size = "sm", + urgentUnderSeconds = 300, +}: CountdownTimerProps) { + const [remaining, setRemaining] = useState(() => + deadline ? new Date(deadline).getTime() - Date.now() : null, + ); + + useEffect(() => { + if (!deadline) { + setRemaining(null); + return; + } + const target = new Date(deadline).getTime(); + const tick = () => setRemaining(target - Date.now()); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [deadline]); + + if (!deadline || remaining == null || Number.isNaN(remaining)) { + return null; + } + + const expired = remaining <= 0; + const urgent = !expired && remaining <= urgentUnderSeconds * 1000; + const color = expired ? "red.7" : urgent ? "orange.7" : "dimmed"; + + return ( + + + {label && ( + + {label} + + )} + + {expired ? expiredText : formatRemaining(remaining)} + + + ); +} + +export default CountdownTimer; diff --git a/packages/ui-common/src/components/CountdownTimer/index.ts b/packages/ui-common/src/components/CountdownTimer/index.ts new file mode 100644 index 000000000..d1f61e322 --- /dev/null +++ b/packages/ui-common/src/components/CountdownTimer/index.ts @@ -0,0 +1,2 @@ +export { CountdownTimer, default } from "./CountdownTimer"; +export type { CountdownTimerProps } from "./CountdownTimer"; diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index 43c39d962..136e9e93b 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -25,6 +25,9 @@ export { useFileViewer } from "./hooks/useFileViewer"; export { OperationDatePicker } from "./components/OperationDatePicker"; export type { OperationDatePickerProps } from "./components/OperationDatePicker"; +export { CountdownTimer } from "./components/CountdownTimer"; +export type { CountdownTimerProps } from "./components/CountdownTimer"; + export { Badge } from "./components/badge"; // export type { BadgeProps } from "./components/badge";