Add booking window management and locomotive scheduling features

- Implemented migration to release stuck assigned locomotives.
- Added schedule window phases to train schedules.
- Created booking batch offers table for partial capacity bookings.
- Developed BookingSplitService to handle partial booking offers and splits.
- Introduced BookingWindowService to manage booking window lifecycle and transitions.
- Added BookingBatchOffer entity to represent offers made during booking splits.
- Enhanced locomotive options with warnings for scheduling.
- Created UpcomingWindowsSection component to display upcoming booking windows.
This commit is contained in:
Marshal
2026-07-03 03:36:06 +00:00
parent 18c158fb61
commit 56de90892d
41 changed files with 2480 additions and 124 deletions

View File

@@ -48,6 +48,7 @@ import type {
} from "@/types/trainScheduling";
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
import { locomotiveOption, showScheduleWarnings } from "./locomotiveOptions";
import {
autoFillPlacements,
mergePlacementsWithSaved,
@@ -274,6 +275,7 @@ export function AllocateBookingWizard({
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveIds },
});
showScheduleWarnings(created.warnings);
setSelectedScheduleId(created.id);
return created.id;
};
@@ -536,10 +538,9 @@ export function AllocateBookingWizard({
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
}))}
data={(locomotivesQuery.data ?? []).map((l) =>
locomotiveOption(l, " · "),
)}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable

View File

@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import { Box, Group, Progress, Text, Tooltip } from "@mantine/core";
import type { BookingWindowPhase } from "@/types/trainScheduling";
import "./batchVisuals.css";
/**
@@ -213,3 +215,69 @@ export function HeroChip({
</Group>
);
}
const PHASE_META: Record<
BookingWindowPhase,
{ color: string; label: string; pulse: boolean }
> = {
PRE_WINDOW: { color: "gray", label: "Pre-window", pulse: false },
OPEN: { color: "edr-green", label: "Booking open", pulse: true },
DOC_REVIEW: { color: "yellow", label: "Doc review", pulse: true },
PAYMENT: { color: "blue", label: "Payment", pulse: true },
CLOSED_FOR_DAY: { color: "dark", label: "Closed for day", pulse: false },
DONE: { color: "dark", label: "Done", pulse: false },
};
/**
* Import booking-cycle phase pill (OPEN → DOC_REVIEW → PAYMENT → …) with an
* optional cycle number. Same visual language as `WindowStatusPill`.
*/
export function WindowPhasePill({
phase,
cycleNo,
size = "md",
}: {
phase: BookingWindowPhase;
cycleNo?: number;
size?: "sm" | "md";
}) {
const meta = PHASE_META[phase] ?? {
color: "gray",
label: phase,
pulse: false,
};
const compact = size === "sm";
return (
<Group
gap={6}
wrap="nowrap"
style={{
display: "inline-flex",
padding: compact ? "2px 8px" : "4px 11px",
borderRadius: 999,
background: `var(--mantine-color-${meta.color}-0)`,
border: `1px solid var(--mantine-color-${meta.color}-2)`,
}}
>
<Box
w={compact ? 6 : 7}
h={compact ? 6 : 7}
className={meta.pulse ? "bb-pulse-dot" : undefined}
style={{
borderRadius: 999,
flexShrink: 0,
background: `var(--mantine-color-${meta.color}-6)`,
}}
/>
<Text
size="xs"
fw={700}
c={`${meta.color}.8`}
style={{ letterSpacing: 0.3, lineHeight: 1, whiteSpace: "nowrap" }}
>
{meta.label}
{cycleNo && cycleNo > 1 ? ` · cycle ${cycleNo}` : ""}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,50 @@
import hotToast from "react-hot-toast";
import type { LocomotiveRecord } from "@/types/trainScheduling";
/**
* Locomotives can now be scheduled in advance: not-at-origin-yard or
* already-on-future-schedules is allowed with a warning (only OUT_OF_SERVICE
* is blocked server-side). This returns the hint to surface in the picker,
* or null when the locomotive is ready at the origin yard.
*/
export function locomotiveWarning(loco: LocomotiveRecord): string | null {
const hints: string[] = [];
if (loco.atOriginYard === false) hints.push("not at origin yard");
const futureCount = loco.futureScheduleCount ?? 0;
if (futureCount > 0) {
hints.push(`on ${futureCount} future schedule${futureCount === 1 ? "" : "s"}`);
}
return hints.length ? hints.join(" · ") : null;
}
/** MultiSelect option for the schedule-creation locomotive picker. */
export function locomotiveOption(
loco: LocomotiveRecord,
nameSeparator = " — ",
): { value: string; label: string } {
const base = `${loco.code}${loco.name ? `${nameSeparator}${loco.name}` : ""}`;
const warning = locomotiveWarning(loco);
return {
value: loco.id,
label: warning ? `${base} · ⚠ ${warning}` : base,
};
}
/**
* Yellow toast listing create-schedule warnings (e.g. locomotive not at the
* origin yard yet). The shared `useToast` hook only knows success/error, so
* this styles a react-hot-toast directly.
*/
export function showScheduleWarnings(warnings?: string[] | null): void {
if (!warnings?.length) return;
hotToast(warnings.join("\n"), {
icon: "⚠️",
duration: 8000,
style: {
background: "var(--mantine-color-yellow-0)",
color: "var(--mantine-color-yellow-9)",
border: "1px solid var(--mantine-color-yellow-4)",
},
});
}