Add countdown timer component and integrate phase deadlines in booking windows

This commit is contained in:
Marshal
2026-07-03 17:11:35 +00:00
parent a5c46505e3
commit 0ac5adcee9
10 changed files with 217 additions and 79 deletions

View File

@@ -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,

View File

@@ -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({
</Box>
</Group>
{(() => {
const cd = phaseCountdown(schedule);
return cd ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-blue-0)",
border: "1px solid var(--mantine-color-blue-2)",
}}
>
<CountdownTimer deadline={cd.deadline} label={cd.label} size="sm" />
</Group>
) : null;
})()}
{over ? (
<Group
gap={8}

View File

@@ -385,6 +385,11 @@ export interface TrainScheduleDetail {
freightType?: FreightType | null;
trainNumber?: string | null;
direction?: string | null;
windowPhase?: BookingWindowPhase | string | null;
windowOpensAt?: string | null;
windowClosesAt?: string | null;
docReviewEndsAt?: string | null;
paymentPhaseEndsAt?: string | null;
route?: {
id: string;
name: string;

View File

@@ -2,6 +2,7 @@ import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { Card } from "./Card";
@@ -43,6 +44,29 @@ function windowLabel(w: MyBookingWindow): string {
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
}
/**
* 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.
*/
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string } | null {
switch (w.windowPhase) {
case "OPEN":
if (w.windowClosesAt) return { label: "Window closes in", deadline: w.windowClosesAt };
return null;
case "DOC_REVIEW":
if (w.docReviewEndsAt) return { label: "Document review ends in", deadline: w.docReviewEndsAt };
return null;
case "PAYMENT":
if (w.paymentPhaseEndsAt) return { label: "Payment due in", deadline: w.paymentPhaseEndsAt };
return null;
default:
return null;
}
}
function Pill({
children,
bg,
@@ -180,6 +204,18 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
</Text>
</Group>
{(() => {
const cd = phaseCountdown(w);
return cd ? (
<Box mt={4}>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
size="xs"
/>
</Box>
) : null;
})()}
</Box>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>

View File

@@ -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({
<KeyFactsStrip booking={booking} />
<ContractCard booking={booking} navigate={navigate} />
<ContractCard booking={booking} />
{isClearance && <ClearanceCard booking={booking} />}
@@ -220,52 +216,6 @@ export function ReadonlyBookingView({
)}
<WarehousePaymentsSection bookingId={booking.id} />
{booking.files && booking.files.length > 0 && (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Documents</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{booking.files.length} files
</Text>
</Group>
<Box>
{booking.files.map((file, i) => (
<DocRow
key={file.id}
last={i === booking.files!.length - 1}
title={file.name}
meta={file.code.replace(/_/g, " ")}
status="verified"
action={
<Group gap={6} wrap="nowrap">
{isViewable({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
}) && (
<IconSquare
icon={<Eye size={16} />}
onClick={() =>
view({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
})
}
/>
)}
<IconSquare
href={fileViewUrl(file.id, true)}
icon={<Download size={16} />}
/>
</Group>
}
/>
))}
</Box>
</SectionCard>
)}
<ActivityCard booking={booking} />
</>
}

View File

@@ -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<typeof useNavigate>;
}) {
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}
</Text>
</Box>
{c.buttonLabel && (
<Button
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
radius={10}
color="edr-green"
leftSection={<FileSignature size={18} />}
styles={{
root: { height: 42, paddingInline: 18 },
label: { fontSize: 13, fontWeight: 700 },
}}
>
{c.buttonLabel}
</Button>
)}
</Group>
</Paper>
);

View File

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

View File

@@ -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<number | null>(() =>
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 (
<Group gap={6} align="center" wrap="nowrap">
<Clock size={size === "lg" ? 18 : 14} />
{label && (
<Text size={size} c="dimmed">
{label}
</Text>
)}
<Text size={size} fw={600} c={color}>
{expired ? expiredText : formatRemaining(remaining)}
</Text>
</Group>
);
}
export default CountdownTimer;

View File

@@ -0,0 +1,2 @@
export { CountdownTimer, default } from "./CountdownTimer";
export type { CountdownTimerProps } from "./CountdownTimer";

View File

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