mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge pull request #1227 from Tria-plc/freight_feature/usermanagement
feat(freight): surface scheduling clock and gate pay during drain
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
export * from "./BookingSchedulingWindowCard";
|
||||
|
||||
Reference in New Issue
Block a user