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;