Merge pull request #701 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-15 13:32:37 +03:00
committed by GitHub
43 changed files with 1900 additions and 312 deletions

View File

@@ -17,7 +17,8 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
import type { BookingWindowUiKind } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
@@ -81,51 +82,40 @@ function windowLabel(w: WindowRow): string {
}
/**
* The countdown for whichever phase the window is currently in, mirroring the
* customer portal. `expiredText` names the NEXT step so a deadline that lapses
* between refetches announces what comes next rather than the bare "Expired".
* The countdown for the window's UI state, mirroring the customer portal.
* Derived from the SAME state as the badge (`bookingWindowUiState`) so they
* can never contradict — a full train shows no ticking countdown.
* `expiredText` names the NEXT step so a deadline that lapses between
* refetches announces what comes next rather than the bare "Expired".
*/
const COUNTDOWN_TEXT: Partial<
Record<BookingWindowUiKind, { label: string; expiredText: string }>
> = {
PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" },
OPEN: { label: "Closes in", expiredText: "Review starting…" },
DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment ends in", expiredText: "Closing…" },
};
function phaseCountdown(
w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Opens in",
deadline: w.windowOpensAt,
expiredText: "Opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Closes in",
deadline: w.windowClosesAt,
expiredText: "Review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Doc review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment ends in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Closing…",
}
: null;
default:
return null;
}
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
OPEN: { label: "Open now", color: "edr-green" },
FULL: { label: "Train full", color: "red" },
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
DOC_REVIEW: { label: "Doc review", color: "gray" },
PAYMENT: { label: "Payment", color: "gray" },
CLOSED: { label: "Closed", color: "gray" },
};
/**
* Drop windows the SERVER considers finished — keyed off windowPhase, never the
* client clock. The server query already excludes terminal / departed rows;
@@ -139,7 +129,9 @@ function isPast(w: WindowRow): boolean {
function WindowCard({ w }: { w: WindowRow }) {
const cd = phaseCountdown(w);
const open = w.isOpenNow;
const state = bookingWindowUiState(w);
const badge = KIND_BADGE[state.kind];
const open = state.isBookable;
const isImport = w.direction === "IMPORT";
return (
@@ -177,13 +169,11 @@ function WindowCard({ w }: { w: WindowRow }) {
)}
<Badge
variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"}
color={badge.color}
radius="sm"
size="sm"
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
{badge.label}
</Badge>
</Group>

View File

@@ -50,7 +50,15 @@ export default function AvailableWagonsPanel({
const typeOptions = useMemo(() => {
const byId = new Map<string, string>();
for (const wagon of wagonsQuery.data ?? []) {
if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name);
if (wagon.wagonType) {
// e.g. "Flat wagon (NW5)" — name with its type code.
byId.set(
wagon.wagonType.id,
wagon.wagonType.code
? `${wagon.wagonType.name} (${wagon.wagonType.code})`
: wagon.wagonType.name,
);
}
}
return [
{ value: "ALL", label: "All types" },
@@ -64,6 +72,22 @@ export default function AvailableWagonsPanel({
);
};
const allSelected =
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
const someSelected = wagons.some((w) => selected.includes(w.id));
const toggleAll = (checked: boolean) => {
setSelected((prev) => {
if (checked) {
const ids = new Set(prev);
wagons.forEach((w) => ids.add(w.id));
return [...ids];
}
const visible = new Set(wagons.map((w) => w.id));
return prev.filter((id) => !visible.has(id));
});
};
const handleAssign = () => {
if (!selected.length) return;
onAssign(selected);
@@ -88,6 +112,16 @@ export default function AvailableWagonsPanel({
/>
</Group>
{wagons.length ? (
<Checkbox
size="sm"
label={`Select all (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
) : null}
<ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}>
{wagonsQuery.isLoading ? (

View File

@@ -26,14 +26,19 @@ const parseError = (error: unknown, fallback: string) => {
return fallback;
};
// Run-number parity carries the trade direction: odd = export, even = import.
const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
/**
* Step one of the Train Builder: give the train its operator code, pick the
* yard it is being assembled in, and couple at least two locomotives from that
* yard. Wagons are attached afterwards on the composition page.
* Step one of the Train Builder: pick the yard it is being assembled in and
* couple at least two locomotives from that yard. The train code is assigned by
* the system. Wagons are attached afterwards on the composition page.
*/
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast();
const [code, setCode] = useState("");
const [exportTrainNumber, setExportTrainNumber] = useState("");
const [importTrainNumber, setImportTrainNumber] = useState("");
const [trainName, setTrainName] = useState("");
const [yardId, setYardId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
@@ -56,7 +61,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
useEffect(() => {
if (!opened) {
setCode("");
setExportTrainNumber("");
setImportTrainNumber("");
setTrainName("");
setYardId("");
setLocomotiveIds([]);
@@ -65,16 +71,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}, [opened]);
const handleBuild = async () => {
if (!code.trim() || !yardId || locomotiveIds.length < 2) {
if (!yardId || locomotiveIds.length < 2) {
toast({
title: "Enter a train code, pick a yard, and couple at least two locomotives",
title: "Pick a yard and couple at least two locomotives",
variant: "destructive",
});
return;
}
if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) {
toast({
title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)",
variant: "destructive",
});
return;
}
try {
const composition = await build.mutateAsync({
code: code.trim(),
exportTrainNumber: exportTrainNumber.trim(),
importTrainNumber: importTrainNumber.trim(),
currentYardId: yardId,
locomotiveIds,
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
@@ -108,22 +122,42 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
<Stack gap="md">
<Text size="sm" c="dimmed">
A train is assembled in one yard: two or more locomotives plus wagons
standing in that same yard. Wagons are attached on the next screen.
standing in that same yard. The train code is assigned automatically;
wagons are attached on the next screen.
</Text>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
/>
<Group grow>
<TextInput
label="Train code"
placeholder="e.g. 81001"
value={code}
onChange={(e) => setCode(e.currentTarget.value)}
maxLength={32}
label="Export train number"
description="Odd — Ethiopia → Djibouti runs"
placeholder="e.g. 8001"
value={exportTrainNumber}
onChange={(e) => setExportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
exportTrainNumber && !isOddNumber(exportTrainNumber)
? "Must be numeric and odd"
: undefined
}
/>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
label="Import train number"
description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002"
value={importTrainNumber}
onChange={(e) => setImportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
importTrainNumber && !isEvenNumber(importTrainNumber)
? "Must be numeric and even"
: undefined
}
/>
</Group>
<Select

View File

@@ -7,7 +7,7 @@ import {
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2 } from "lucide-react";
import { GripVertical, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
@@ -36,6 +36,7 @@ export default function ConsistWagonList({
editable,
onReorder,
onRemove,
onMaintenance,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => {
@@ -78,6 +79,7 @@ export default function ConsistWagonList({
editable={editable}
busy={busy}
onRemove={onRemove}
onMaintenance={onMaintenance}
/>
)}
</Draggable>
@@ -95,6 +97,8 @@ export interface ConsistWagonListProps {
editable: boolean;
onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status. */
onMaintenance: (wagonId: string) => void;
busy?: boolean;
}
@@ -106,6 +110,7 @@ function WagonRow({
editable,
busy,
onRemove,
onMaintenance,
}: {
wagon: TrainCompositionWagon;
index: number;
@@ -114,6 +119,7 @@ function WagonRow({
editable: boolean;
busy: boolean;
onRemove: (wagonId: string) => void;
onMaintenance: (wagonId: string) => void;
}) {
return (
<PortalAwareRow snapshot={snapshot}>
@@ -153,17 +159,30 @@ function WagonRow({
</Text>
</Stack>
{editable ? (
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
<Group gap={4} wrap="nowrap">
<Tooltip label="Send to maintenance (detaches)" withArrow>
<ActionIcon
variant="subtle"
color="orange"
disabled={busy}
onClick={() => onMaintenance(wagon.id)}
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
>
<Wrench size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
) : null}
</Group>
</PortalAwareRow>

View File

@@ -1,3 +1,5 @@
import type { CSSProperties } from "react";
import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
/** Badge color per built-train lifecycle status (Mantine palette keys). */
@@ -23,3 +25,17 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
/** Row background tint for a train whose active schedule runs in `direction`. */
export const directionRowStyle = (
direction?: string | null,
): CSSProperties | undefined =>
direction === "IMPORT"
? { backgroundColor: "var(--mantine-color-blue-0)" }
: direction === "EXPORT"
? { backgroundColor: "var(--mantine-color-orange-0)" }
: undefined;

View File

@@ -152,6 +152,9 @@ export function ScheduleWorkspacePanel({
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const assignUnassigned = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(),
@@ -166,6 +169,12 @@ export function ScheduleWorkspacePanel({
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
// Pool → pick a same-day schedule with free wagons and place the booking there.
const [poolAssign, setPoolAssign] = useState<{ id: string; reference: string } | null>(
null,
);
const [poolTarget, setPoolTarget] = useState<string | null>(null);
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
@@ -190,6 +199,22 @@ export function ScheduleWorkspacePanel({
[targets, schedule.id],
);
// Every schedule departing on THIS train's day (EAT) — a paid booking waiting
// for a wagon may board any of them, so staff pick whichever has wagons free.
const eatDayOf = (iso: string) =>
new Date(iso).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa" });
const sameDayOptions = useMemo(() => {
const day = eatDayOf(schedule.scheduledDepartureDate);
return (targets ?? [])
.filter((s) => eatDayOf(s.scheduleDate) === day)
.map((s) => ({
value: s.id,
label: `${s.id === schedule.id ? "This train · " : ""}${
s.routeName ?? `${s.origin}${s.destination}`
} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
}));
}, [targets, schedule.id, schedule.scheduledDepartureDate]);
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule);
const capacity = pullCapacity(schedule);
@@ -288,6 +313,36 @@ export function ScheduleWorkspacePanel({
);
};
// Point the pool booking at the chosen same-day train, then put it on wagons.
// If the wagon step fails (that train is short too) the booking stays paid &
// unassigned in the pool — nothing is lost, staff just pick another train.
const doPoolAssign = () => {
if (!poolAssign || !poolTarget) return;
const { id: bookingId, reference } = poolAssign;
moveSchedule
.mutateAsync({ bookingId, trainScheduleId: poolTarget })
.then(() => assignUnassigned.mutateAsync({ id: poolTarget, bookingId }))
.then(() => {
toast({
title: `${reference} assigned`,
description: "Booking placed on the selected train with wagons pinned.",
});
setPoolAssign(null);
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: `Could not assign ${reference}`,
description: apiErrorMessage(
error,
"The selected train has no free wagon of the required type.",
),
variant: "destructive",
}),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
@@ -465,20 +520,41 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"}
right={
canManage ? (
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
<Group gap={6} wrap="nowrap" justify="flex-end">
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
>
Add
</Button>
</Tooltip>
<Tooltip
label="Pick any train departing this day that has wagons free"
withArrow
>
Add
</Button>
</Tooltip>
<Button
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
leftSection={<ArrowLeftRight size={13} />}
onClick={() => {
setPoolAssign({ id: b.id, reference: b.reference });
setPoolTarget(null);
}}
>
Add to
</Button>
</Tooltip>
</Group>
) : null
}
/>
@@ -588,6 +664,52 @@ export function ScheduleWorkspacePanel({
</Group>
</Stack>
{/* Pool → same-day train assignment modal */}
<Modal
opened={Boolean(poolAssign)}
onClose={() => setPoolAssign(null)}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
Assign {poolAssign?.reference ?? "booking"} to a train on this day
</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Text size="xs" c="dimmed">
All open trains departing on this schedule&apos;s day. Pick one with
free wagons the booking is placed and its wagons pinned in one step.
</Text>
<Select
label="Target train (same day)"
placeholder="Select a departure"
data={sameDayOptions}
value={poolTarget}
onChange={setPoolTarget}
searchable
nothingFoundMessage="No open schedules depart on this day"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPoolAssign(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!poolTarget}
loading={moveSchedule.isPending || assignUnassigned.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doPoolAssign}
>
Assign to train
</Button>
</Group>
</Stack>
</Modal>
{/* Reassign modal */}
<Modal
opened={Boolean(moveBookingId)}
@@ -710,6 +832,7 @@ function BookingCard({
weightTons,
status,
loadingStatus,
waitingForWagon,
right,
}: {
reference: string;
@@ -717,6 +840,8 @@ function BookingCard({
weightTons?: number | null;
status?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
/** Paid, but no wagon of the required type was free — waiting for one. */
waitingForWagon?: boolean;
right?: React.ReactNode;
}) {
return (
@@ -742,6 +867,16 @@ function BookingCard({
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{waitingForWagon ? (
<Tooltip
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
withArrow
>
<Badge size="sm" radius="sm" variant="light" color="orange">
Waiting for wagon
</Badge>
</Tooltip>
) : null}
{loadingStatus ? (
<Badge
size="sm"

View File

@@ -32,7 +32,11 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import {
directionColor,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
@@ -72,12 +76,18 @@ export default function TrainBuilderDetailPage() {
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
const maintenanceWagon = useMutation(
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
);
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const composition = compositionQuery.data;
const busy =
assignWagons.isPending || removeWagon.isPending || reorderWagons.isPending;
assignWagons.isPending ||
removeWagon.isPending ||
maintenanceWagon.isPending ||
reorderWagons.isPending;
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
try {
@@ -128,9 +138,17 @@ export default function TrainBuilderDetailPage() {
}
backTo="/dashboard/train-builder"
meta={
<Badge color={trainStatusColor(composition.status)} variant="light">
{trainStatusLabel(composition.status)}
</Badge>
<Group gap="xs">
<Badge color={trainStatusColor(composition.status)} variant="light">
{trainStatusLabel(composition.status)}
</Badge>
<Badge color="blue" variant="light" ff="monospace">
IMP {composition.importTrainNumber ?? "—"}
</Badge>
<Badge color="orange" variant="light" ff="monospace">
EXP {composition.exportTrainNumber ?? "—"}
</Badge>
</Group>
}
action={
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
@@ -272,6 +290,12 @@ export default function TrainBuilderDetailPage() {
"Could not detach wagon",
)
}
onMaintenance={(wagonId) =>
void withToast(
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not send wagon to maintenance",
)
}
/>
</Stack>
</Card>
@@ -289,6 +313,16 @@ export default function TrainBuilderDetailPage() {
<Text size="sm" ff="monospace" fw={600}>
{schedule.reference ?? schedule.id.slice(0, 8)}
</Text>
{schedule.trainNumber ? (
<Text size="sm" ff="monospace" fw={700}>
{schedule.trainNumber}
</Text>
) : null}
{schedule.direction ? (
<Badge size="sm" variant="light" color={directionColor(schedule.direction)}>
{schedule.direction}
</Badge>
) : null}
<Badge size="sm" variant="light">
{schedule.status}
</Badge>

View File

@@ -27,7 +27,12 @@ import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import {
directionColor,
directionRowStyle,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
import { api } from "@/services/api";
import type {
BuiltTrainListFilters,
@@ -146,6 +151,32 @@ export default function TrainBuilderListPage() {
</Group>
),
},
{
id: "numbers",
header: "Train No.",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const active = row.original.activeSchedule;
return (
<Stack gap={2}>
{active?.trainNumber ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
{active.direction ?? "—"}
</Badge>
</Group>
) : null}
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
{row.original.exportTrainNumber ?? "—"}
</Text>
</Stack>
);
},
},
{
id: "yard",
header: "Yard",
@@ -293,6 +324,7 @@ export default function TrainBuilderListPage() {
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
error={
trainsQuery.isError
? {

View File

@@ -244,6 +244,21 @@ export default function TrainScheduleV2DetailPage() {
return [];
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
// EXPORT schedules render the consist back-to-front (the train turns around
// for the return run) — DISPLAY ONLY: stored sequenceNos, allocations,
// documents, and the adjust-consist / placement flows keep the as-built order.
const isExportDisplay = schedule?.direction === "EXPORT";
const displayWagonPlanOriented = useMemo(
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
[displayWagonPlan, isExportDisplay],
);
const diagramWagons = useMemo(() => {
const source = schedule?.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan;
return isExportDisplay ? [...source].reverse() : source;
}, [schedule?.trainSet?.wagons, displayWagonPlan, isExportDisplay]);
const runPreview = useCallback(
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
if (!schedule || !scheduleId) return null;
@@ -683,7 +698,12 @@ export default function TrainScheduleV2DetailPage() {
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
{isExportDisplay && displayWagonPlanOriented.length ? (
<Text size="xs" c="dimmed">
Shown rear-first (export direction) positions keep their original numbers.
</Text>
) : null}
<WagonPlanGrid wagonPlan={displayWagonPlanOriented} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
@@ -760,15 +780,16 @@ export default function TrainScheduleV2DetailPage() {
<TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive}
locomotives={locomotives}
wagons={
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
wagons={diagramWagons}
freightType={freightType}
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/>
{isExportDisplay && diagramWagons.length ? (
<Text size="xs" c="dimmed">
Shown rear-first (export direction) positions keep their original numbers.
</Text>
) : null}
<Paper
p="lg"
radius="lg"
@@ -885,6 +906,11 @@ export default function TrainScheduleV2DetailPage() {
{schedule.trainNumber}
</Badge>
) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
Train {schedule.train.code}
</Text>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor

View File

@@ -327,19 +327,24 @@ export default function TrainScheduleV2ListPage() {
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
// Schedules created from the Train Builder carry the train code;
// legacy rows fall back to their locomotive set.
// Schedules created from the Train Builder show the direction-matched
// run number first (falling back to the train code); legacy rows fall
// back to their locomotive set.
if (row.original.train) {
const subtitle = [row.original.trainNumber ? row.original.train.code : null,
row.original.train.trainName]
.filter(Boolean)
.join(" · ");
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.train.code}
{row.original.trainNumber ?? row.original.train.code}
</Text>
{row.original.train.trainName ? (
{subtitle ? (
<Text size="xs" c="dimmed" lh={1.2}>
{row.original.train.trainName}
{subtitle}
</Text>
) : null}
</Stack>
@@ -758,14 +763,21 @@ export default function TrainScheduleV2ListPage() {
label="Train"
description="A built train (Train Builder) runs this departure with its locomotives and wagons"
placeholder={routeId ? "Select a train" : "Select a route first"}
data={(trainsQuery.data ?? []).map((train) => ({
value: train.id,
label: `${train.code}${train.trainName ? `${train.trainName}` : ""} · ${
train.locomotives.length
} locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${
train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""
}`,
}))}
data={(trainsQuery.data ?? []).map((train) => {
// Route direction picks which of the train's typed pair this run uses.
const runNumber =
selectedRoute?.direction === "IMPORT"
? train.importTrainNumber
: train.exportTrainNumber;
return {
value: train.id,
label: `${train.code}${train.trainName ? `${train.trainName}` : ""}${
runNumber ? ` · runs as ${runNumber}` : ""
} · ${train.locomotives.length} locos · ${train.wagonCount} wagons${
train.atOriginYard ? "" : " · not at origin yard"
}${train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""}`,
};
})}
value={trainId || null}
onChange={(v) => setTrainId(v ?? "")}
searchable

View File

@@ -62,7 +62,6 @@ export default function TrainSchedulingGlobalRulesPage() {
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"reopenDelayMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
@@ -261,17 +260,6 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
<DurationField
label="Reopen delay"
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
disabled={loading}
/>
<Group justify="flex-end">
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules

View File

@@ -1850,6 +1850,15 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
"train-builder",
"sendWagonToMaintenance",
({ id, wagonId }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",

View File

@@ -17,11 +17,27 @@ export interface YardRefLite {
label: string;
}
export type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
export interface ActiveScheduleRef {
id: string;
status: string;
reference: string | null;
direction: TradeDirection | null;
trainNumber: string | null;
}
export interface BuiltTrainSummary {
id: string;
code: string;
trainName: string | null;
status: BuiltTrainStatus;
/** Fixed IMPORT (even) run number typed at build time. */
importTrainNumber: string | null;
/** Fixed EXPORT (odd) run number typed at build time. */
exportTrainNumber: string | null;
activeSchedule: ActiveScheduleRef | null;
createdAt: string;
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
@@ -80,13 +96,15 @@ export interface TrainComposition {
code: string;
trainName: string | null;
status: BuiltTrainStatus;
importTrainNumber: string | null;
exportTrainNumber: string | null;
notes: string | null;
createdAt: string;
currentYard: YardRefLite | null;
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
totals: TrainCompositionTotals;
activeSchedules: Array<{ id: string; status: string; reference: string | null }>;
activeSchedules: ActiveScheduleRef[];
editable: boolean;
}
@@ -111,7 +129,10 @@ export interface BuiltTrainListResponse {
}
export interface BuildTrainPayload {
code: string;
/** EXPORT run number — odd, unique across trains (e.g. 8001). */
exportTrainNumber: string;
/** IMPORT run number — even, unique across trains (e.g. 8002). */
importTrainNumber: string;
currentYardId: string;
locomotiveIds: string[];
wagonIds?: string[];
@@ -125,6 +146,8 @@ export interface AvailableTrain {
code: string;
trainName: string | null;
status: BuiltTrainStatus;
importTrainNumber: string | null;
exportTrainNumber: string | null;
currentYardId: string | null;
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
@@ -225,6 +248,9 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
/** Detach a wagon and move it to MAINTENANCE status. */
sendWagonToMaintenance: (id: string, wagonId: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),

View File

@@ -7,7 +7,8 @@ export type SchedulingStatus =
| "HOLDING"
| "ELIGIBLE"
| "SCHEDULED"
| "DISPATCHED";
| "DISPATCHED"
| "WAITING_FOR_WAGON";
export type TrainScheduleStatus =
| "DRAFT"
@@ -94,10 +95,20 @@ export interface FleetAvailabilityRow {
shortfall: number;
}
/** Per-booking wagon shortage: how many wagons of which type the booking still lacks. */
export interface BookingWagonShortage {
wagonTypeCodes: string;
wagonsNeeded: number;
wagonsAvailable: number;
wagonsShort: number;
}
export interface DeferredBookingRow {
id: string;
reference: string;
reason: string;
/** Set when the deferral is a fleet-stock shortage (absent for config issues). */
shortage?: BookingWagonShortage | null;
}
export interface TrainSchedulingGlobalRules {
@@ -114,7 +125,6 @@ export interface TrainSchedulingGlobalRules {
windowDurationHours: number;
docReviewMinutes: number;
paymentWindowMinutes: number;
reopenDelayMinutes: number;
}
export interface TrainSchedulePreviewResponse {
@@ -493,7 +503,6 @@ export interface ScheduleWindowRule {
windowOpenHour: number | null;
windowCloseHour: number | null;
windowDurationHours: number | null;
reopenDelayMinutes: number | null;
importWindowLeadDays: number | null;
exportBookingLeadHours: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
@@ -831,6 +840,7 @@ export interface CompositionUnassignedBooking {
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
shortage?: BookingWagonShortage | null;
}
export interface UnassignedBookingsResponse {

View File

@@ -0,0 +1,264 @@
import { describe, expect, it } from "vitest";
import { bookingWindowUiState } from "@edr/ui-common";
import type {
BookingWindowStateInput,
BookingWindowUiState,
} from "@edr/ui-common";
/**
* Scenario table for the shared badge/countdown state. This is the logic that
* previously let a full export train show an "Upcoming" badge above a live
* "Window closes in …" countdown — every row asserts badge kind, countdown
* target, and bookability TOGETHER, so they can never disagree again.
*/
const OPENS = "2026-07-26T05:00:00.000Z";
const CLOSES = "2026-07-27T05:00:00.000Z";
const DOC_ENDS = "2026-07-24T08:30:00.000Z";
const PAY_ENDS = "2026-07-24T09:30:00.000Z";
/** A full row with every timestamp present; scenarios override what they test. */
function row(over: Partial<BookingWindowStateInput>): BookingWindowStateInput {
return {
windowPhase: "OPEN",
bookingWindowStatus: "OPEN",
windowOpensAt: OPENS,
windowClosesAt: CLOSES,
docReviewEndsAt: DOC_ENDS,
paymentPhaseEndsAt: PAY_ENDS,
...over,
};
}
interface Scenario {
name: string;
input: BookingWindowStateInput;
expected: BookingWindowUiState;
}
const scenarios: Scenario[] = [
// ---- export FCFS lifecycle -------------------------------------------------
{
name: "export announced, before lead window (PRE_WINDOW/CLOSED)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "export window open, space left (OPEN/OPEN)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "export filled mid-window (OPEN/FULL) — the reported bug",
input: row({ bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "export space freed after an expiry cleared FULL (OPEN/OPEN again)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "export window over (DONE/CLOSED)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "export departed while full (DONE/FULL)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- import daily cycle ----------------------------------------------------
{
name: "import before booking day (PRE_WINDOW/CLOSED)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "import window open (OPEN/OPEN)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "import window closed, staff reviewing docs (DOC_REVIEW/CLOSED)",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "import payment phase, selected customers paying (PAYMENT/CLOSED)",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
{
name: "import batch tentatively filled the train (PAYMENT/FULL) — phase wins, unpaid may still free space",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "FULL" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
{
name: "import doc review while flag already FULL (DOC_REVIEW/FULL) — phase wins",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "FULL" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "import reopen cycle scheduled (PRE_WINDOW/CLOSED, cycle 2)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "import reopen refused while train still FULL (PRE_WINDOW/FULL)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "import train full and finalized (DONE/FULL)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "import no cycle fits before departure (DONE/CLOSED)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "legacy closed-for-the-day row (CLOSED_FOR_DAY/CLOSED)",
input: row({ windowPhase: "CLOSED_FOR_DAY", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "legacy closed-for-the-day row while full (CLOSED_FOR_DAY/FULL)",
input: row({ windowPhase: "CLOSED_FOR_DAY", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- desync / stale rows ---------------------------------------------------
{
name: "phase OPEN but desk flag CLOSED (desync) — closed, no countdown",
input: row({ bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "dispatched train stuck at OPEN/CLOSED (tick skips non-scheduled rows)",
input: row({ bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "FULL flag with no phase at all (legacy pre-window-engine row)",
input: row({ windowPhase: null, bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "legacy row, no phase, desk open (null/OPEN) — not phase-driven, shows closed",
input: row({ windowPhase: null }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "unknown future phase value — safe fallback to closed",
input: row({ windowPhase: "SOMETHING_NEW" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- missing timestamps (no countdown, badge still right) -------------------
{
name: "PRE_WINDOW without an opens-at timestamp",
input: row({
windowPhase: "PRE_WINDOW",
bookingWindowStatus: "CLOSED",
windowOpensAt: null,
}),
expected: { kind: "PRE_WINDOW", countdownTo: null, isBookable: false },
},
{
name: "OPEN without a closes-at timestamp",
input: row({ windowClosesAt: null }),
expected: { kind: "OPEN", countdownTo: null, isBookable: true },
},
{
name: "DOC_REVIEW without an ends-at timestamp",
input: row({
windowPhase: "DOC_REVIEW",
bookingWindowStatus: "CLOSED",
docReviewEndsAt: null,
}),
expected: { kind: "DOC_REVIEW", countdownTo: null, isBookable: false },
},
{
name: "PAYMENT without an ends-at timestamp",
input: row({
windowPhase: "PAYMENT",
bookingWindowStatus: "CLOSED",
paymentPhaseEndsAt: null,
}),
expected: { kind: "PAYMENT", countdownTo: null, isBookable: false },
},
{
name: "row with every field null",
input: {
windowPhase: null,
bookingWindowStatus: null,
windowOpensAt: null,
windowClosesAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
},
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "row with every field undefined (structural minimum)",
input: {},
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- countdown targets track the right deadline per phase -------------------
{
name: "PRE_WINDOW counts to opens-at, not closes-at",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "OPEN counts to closes-at, not doc review",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "DOC_REVIEW counts to review end, not payment end",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "PAYMENT counts to payment end, not window close",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
];
describe("bookingWindowUiState", () => {
it.each(scenarios)("$name", ({ input, expected }) => {
expect(bookingWindowUiState(input)).toEqual(expected);
});
it("never yields a countdown on a non-bookable FULL state, whatever else is set", () => {
for (const phase of ["OPEN", "PRE_WINDOW", null, "ANYTHING"]) {
const state = bookingWindowUiState(row({ windowPhase: phase, bookingWindowStatus: "FULL" }));
expect(state.kind).toBe("FULL");
expect(state.countdownTo).toBeNull();
expect(state.isBookable).toBe(false);
}
});
it("is bookable ONLY when phase and desk flag are both OPEN", () => {
const combos: Array<[string | null, string | null]> = [];
for (const phase of ["PRE_WINDOW", "OPEN", "DOC_REVIEW", "PAYMENT", "DONE", "CLOSED_FOR_DAY", null]) {
for (const status of ["OPEN", "CLOSED", "FULL", null]) {
combos.push([phase, status]);
}
}
for (const [phase, status] of combos) {
const state = bookingWindowUiState(
row({ windowPhase: phase, bookingWindowStatus: status }),
);
expect(state.isBookable).toBe(phase === "OPEN" && status === "OPEN");
}
});
});

View File

@@ -6,7 +6,7 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { windowRouteStops } from "@/pages/contracts/booking-window";
import { Card } from "./Card";
@@ -50,54 +50,34 @@ function windowLabel(w: MyBookingWindow): string {
}
/**
* The countdown for whichever phase the window is currently in. Phases run:
* pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) →
* document review (docReviewEndsAt) → payment (paymentPhaseEndsAt).
* The countdown for the window's UI state (shared with the status badge via
* `bookingWindowUiState`, so the two can never contradict — a FULL train shows
* no ticking "closes in" under a non-open badge).
*
* `label` describes the deadline being counted down to; `expiredText` names the
* NEXT step so that when a deadline lapses between the 60s refetches the row
* announces what comes next ("Booking opening now…", "Review starting…") rather
* than the bare word "Expired". Returns null when no phase is timing down.
*/
const COUNTDOWN_TEXT: Partial<
Record<
ReturnType<typeof bookingWindowUiState>["kind"],
{ label: string; expiredText: string }
>
> = {
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
};
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
if (w.windowOpensAt)
return {
label: "Booking opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
};
return null;
case "OPEN":
if (w.windowClosesAt)
return {
label: "Window closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
};
return null;
case "DOC_REVIEW":
if (w.docReviewEndsAt)
return {
label: "Document review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
};
return null;
case "PAYMENT":
if (w.paymentPhaseEndsAt)
return {
label: "Payment due in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
};
return null;
default:
return null;
}
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
}
function Pill({
@@ -147,19 +127,47 @@ function DirectionBadge({ direction }: { direction: MyBookingWindow["direction"]
}
function StatusBadge({ window: w }: { window: MyBookingWindow }) {
if (w.isOpenNow) {
return (
<Pill bg="#ECF6F1" color="#0A6F4D" border="#CDEBDD">
Open now
</Pill>
);
}
if (w.windowPhase === "PRE_WINDOW" && w.windowOpensAt) {
return (
<Pill bg="#FEF6E6" color="#B07D14">
Opens at {fmtTime(w.windowOpensAt)} EAT
</Pill>
);
const state = bookingWindowUiState(w);
switch (state.kind) {
case "OPEN":
return (
<Pill bg="#ECF6F1" color="#0A6F4D" border="#CDEBDD">
Open now
</Pill>
);
case "FULL":
return (
<Pill bg="#FDECEA" color="#B3261E" border="#F6C9C4">
Train full
</Pill>
);
case "PRE_WINDOW":
if (w.windowOpensAt) {
return (
<Pill bg="#FEF6E6" color="#B07D14">
Opens at {fmtTime(w.windowOpensAt)} EAT
</Pill>
);
}
break;
case "DOC_REVIEW":
return (
<Pill bg="#EAF1FB" color="#2E5B96">
Document review
</Pill>
);
case "PAYMENT":
return (
<Pill bg="#EAF1FB" color="#2E5B96">
Payment window
</Pill>
);
case "CLOSED":
return (
<Pill bg="#F1F5F9" color={MUTED}>
Closed
</Pill>
);
}
return (
<Pill bg="#F1F5F9" color={MUTED}>

View File

@@ -18,7 +18,8 @@ import {
ChevronRight,
Clock,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
import type { BookingWindowUiKind } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import {
@@ -90,50 +91,29 @@ function windowLabel(w: MyBookingWindow): string {
}
/**
* The countdown for whichever phase the window is currently in, mirroring the
* home dashboard's Booking Windows card. `expiredText` names the NEXT step so a
* deadline that lapses between refetches announces what comes next rather than
* the bare "Expired".
* The countdown for the window's UI state, mirroring the home dashboard's
* Booking Windows card. Derived from the SAME state as the badge
* (`bookingWindowUiState`) so they can never contradict — a full train shows
* no ticking countdown. `expiredText` names the NEXT step so a deadline that
* lapses between refetches announces what comes next rather than the bare
* "Expired".
*/
const COUNTDOWN_TEXT: Partial<
Record<BookingWindowUiKind, { label: string; expiredText: string }>
> = {
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
};
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Booking opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Window closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Document review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment due in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
}
: null;
default:
return null;
}
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
}
/**
@@ -148,9 +128,21 @@ function isPast(w: MyBookingWindow): boolean {
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
OPEN: { label: "Open now", color: "edr-green" },
FULL: { label: "Train full", color: "red" },
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
DOC_REVIEW: { label: "Document review", color: "gray" },
PAYMENT: { label: "Payment due", color: "gray" },
CLOSED: { label: "Closed", color: "gray" },
};
function WindowCard({ w }: { w: MyBookingWindow }) {
const cd = phaseCountdown(w);
const open = w.isOpenNow;
const state = bookingWindowUiState(w);
const badge = KIND_BADGE[state.kind];
const open = state.isBookable;
const isImport = w.direction === "IMPORT";
return (
@@ -183,13 +175,11 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
)}
<Badge
variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"}
color={badge.color}
radius="sm"
size="sm"
>
{open
? "Open now"
: windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
{badge.label}
</Badge>
</Group>