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