remove reopen delay minutes from global rules and update related types

- Removed the  field from  and related components.
- Updated  to reflect the removal of the reopen delay input field.
- Modified  to include new train number fields:  and .
- Added  interface to manage active schedules with trade direction.
- Introduced  interface to track wagon shortages in bookings.
- Updated  logic to ensure consistent UI state representation.
- Created migrations to drop the  column and add  and  columns to the  table.
- Added tests for the new booking window display logic and wagon planning functionality.
This commit is contained in:
Marshal
2026-07-15 09:13:02 +00:00
parent 9be7f356f0
commit 11771e5f92
39 changed files with 1731 additions and 269 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

@@ -26,6 +26,10 @@ 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
@@ -34,6 +38,8 @@ const parseError = (error: unknown, fallback: string) => {
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[]>([]);
@@ -57,6 +63,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
useEffect(() => {
if (!opened) {
setCode("");
setExportTrainNumber("");
setImportTrainNumber("");
setTrainName("");
setYardId("");
setLocomotiveIds([]);
@@ -72,9 +80,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
});
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() } : {}),
@@ -126,6 +143,34 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
maxLength={100}
/>
</Group>
<Group grow>
<TextInput
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="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
label="Build yard"
placeholder="Select the yard the train is assembled in"

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";
@@ -128,9 +132,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}>
@@ -289,6 +301,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

@@ -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;
}
@@ -112,6 +130,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 +147,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 }>;

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");
}
});
});