feat(freight): surface scheduling clock and gate pay during drain

This commit is contained in:
marshalyordanos
2026-08-10 21:47:28 +03:00
parent d3096939e7
commit 40672e9c41
14 changed files with 524 additions and 25 deletions

View File

@@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import { CalendarClock } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { SectionCard } from "./SectionCard";
export interface BookingSchedulingWindowCardProps {
booking: BookingDetail;
}
/** Full date + time — staff read these against the operating clock, so no time is dropped. */
function formatStamp(iso: string | null | undefined): string | null {
if (!iso) return null;
const ms = new Date(iso).getTime();
if (!Number.isFinite(ms)) return null;
return new Date(ms).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */
function formatRelative(iso: string, nowMs: number): string {
const diff = new Date(iso).getTime() - nowMs;
const past = diff < 0;
const totalMinutes = Math.floor(Math.abs(diff) / 60_000);
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
// Keep minutes when they're the only unit, so sub-hour gaps never read "0".
if (minutes || parts.length === 0) parts.push(`${minutes}m`);
const span = parts.slice(0, 2).join(" ");
return past ? `${span} ago` : `in ${span}`;
}
function Row({
label,
value,
hint,
tone,
}: {
label: string;
value: string;
hint?: string | null;
tone?: "muted" | "warning" | "danger";
}) {
const valueColor =
tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark";
return (
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="md">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Box style={{ textAlign: "right", minWidth: 0 }}>
<Text size="sm" fw={600} c={valueColor}>
{value}
</Text>
{hint ? (
<Text size="xs" c="dimmed">
{hint}
</Text>
) : null}
</Box>
</Group>
);
}
/**
* Backoffice-only staff view of the scheduling clock: which batch/train the
* booking is scheduled for, when its pay window closes, and the train's
* planned vs actual departure/arrival (i.e. when the run actually ended).
*/
export function BookingSchedulingWindowCard({
booking,
}: BookingSchedulingWindowCardProps) {
const schedule = booking.trainScheduleSummary ?? null;
// The pay-window end staff should quote is the drain end (a payment landing
// inside the drain still counts); fall back to the raw deadline if the API
// predates that field.
const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null;
// One shared ticking clock so every relative label in the card stays in sync.
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const interval = setInterval(() => setNowMs(Date.now()), 30_000);
return () => clearInterval(interval);
}, []);
const hasAnything =
Boolean(schedule) || Boolean(payWindowEndsAt) || Boolean(booking.holdExpiresAt);
if (!hasAnything) return null;
const payWindowClosed = payWindowEndsAt
? new Date(payWindowEndsAt).getTime() <= nowMs
: false;
const trainLabel =
schedule?.trainNumber ??
schedule?.reference ??
(schedule ? "Assigned train" : null);
return (
<SectionCard
icon={CalendarClock}
title="Scheduling & payment window"
subtitle="Staff view — batch allocation and the operating clock"
accent="indigo"
extra={<SchedulingStatusBadge status={booking.schedulingStatus} />}
>
<Stack gap="sm">
{trainLabel ? (
<Row
label="Scheduled on train"
value={trainLabel}
hint={
schedule?.reference && schedule.reference !== trainLabel
? schedule.reference
: null
}
/>
) : (
<Row
label="Scheduled on train"
value="Not yet allocated"
tone="muted"
hint="The booking has not been placed on a train schedule"
/>
)}
{schedule?.status ? (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
Train status
</Text>
<Group gap="xs">
{schedule.windowPhase ? (
<Badge variant="light" color="gray" size="sm">
{schedule.windowPhase.replace(/_/g, " ")}
</Badge>
) : null}
<Badge variant="light" color="indigo" size="sm">
{schedule.status}
</Badge>
</Group>
</Group>
) : null}
{payWindowEndsAt ? (
<Row
label="Payment window ends"
value={formatStamp(payWindowEndsAt) ?? "—"}
tone={payWindowClosed ? "danger" : "warning"}
hint={
payWindowClosed
? `Closed ${formatRelative(payWindowEndsAt, nowMs)}`
: `Closes ${formatRelative(payWindowEndsAt, nowMs)}`
}
/>
) : null}
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Row
label="Wagon hold expires"
value={formatStamp(booking.holdExpiresAt) ?? "—"}
tone="warning"
hint={formatRelative(booking.holdExpiresAt, nowMs)}
/>
) : null}
{schedule ? (
<>
<Row
label="Departure"
value={
formatStamp(schedule.actualDepartureAt) ??
formatStamp(schedule.scheduledDepartureDate) ??
"—"
}
hint={
schedule.actualDepartureAt
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
: "Planned"
}
/>
<Row
label={schedule.actualArrivalAt ? "Arrived (trip ended)" : "Arrival"}
value={
formatStamp(schedule.actualArrivalAt) ??
formatStamp(schedule.scheduledArrivalDate) ??
"—"
}
hint={
schedule.actualArrivalAt
? `Actual · planned ${formatStamp(schedule.scheduledArrivalDate) ?? "—"}`
: "Planned — the train has not arrived yet"
}
/>
</>
) : null}
</Stack>
</SectionCard>
);
}

View File

@@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";
export * from "./BookingCompanyCard";
export * from "./BookingSchedulingWindowCard";

View File

@@ -40,6 +40,7 @@ import {
BookingCompanyCard,
BookingContractSummaryCard,
BookingContainerUnitsCard,
BookingSchedulingWindowCard,
BookingDocumentsPanel,
BookingTrucksPanel,
ContractOrdersPanel,
@@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingSchedulingWindowCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard

View File

@@ -131,6 +131,20 @@ export interface BookingFile {
size?: number;
}
/** The allocated train's identity, window phase, and planned/actual clock. */
export interface BookingTrainScheduleSummary {
id: string;
reference: string | null;
trainNumber: string | null;
status: string | null;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
actualDepartureAt: string | null;
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
}
export interface BookingDetail {
id: string;
reference: string;
@@ -188,6 +202,17 @@ export interface BookingDetail {
wagonsRequired?: number | null;
scheduledAt?: string | null;
trainScheduleId?: string | null;
/** Operational status of the allocated train (null until scheduled). */
trainScheduleStatus?: string | null;
/** The allocated train's identity + clock, attached by the detail endpoint. */
trainScheduleSummary?: BookingTrainScheduleSummary | null;
/** End of this booking's pay window (batch/offer deadline). */
paymentDeadline?: string | null;
/**
* End of the pay window including the settlement drain tail — the deadline
* staff should quote, since a payment landing inside the drain still counts.
*/
paymentDrainEndsAt?: string | null;
pnrCode?: string | null;
firstMilePickupAddress?: string | null;
lastMileDeliveryAddress?: string | null;