mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Add countdown timer component and integrate phase deadlines in booking windows
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CountdownTimer, default } from "./CountdownTimer";
|
||||
export type { CountdownTimerProps } from "./CountdownTimer";
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user