mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
Merge pull request #701 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user