feat(train-scheduling): implement day-level booking pool

- Added `unplaced` method in `BookingNotifierService` to log warnings for bookings that cannot be placed on any train.
- Introduced `getAvailableDays` method in `TrainSchedulingService` to retrieve distinct days with open departures for a given route.
- Created `AvailableDaysQueryDto` for querying available days based on origin and destination yards.
- Updated `TrainSchedulingController` to expose an endpoint for available days.
- Modified frontend components to support day-level booking, allowing customers to select only a day without pinning to a specific train.
- Removed references to train schedules in booking forms and review steps, emphasizing day selection.
- Added a database migration to create an index for efficient querying of bookings by route and day.
This commit is contained in:
Marshal
2026-06-18 14:12:46 +00:00
parent 578012dffd
commit 421e0266bc
22 changed files with 626 additions and 332 deletions

View File

@@ -302,8 +302,9 @@ export default function NewBookingPage() {
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
const scheduleSatisfied =
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
// Day-level pool: a shipment DAY is enough to proceed. Pinning a specific train
// (trainScheduleId) is an optional staff override — the batch engine otherwise
// assigns the train. A selected schedule implies its day, so either satisfies.
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
const canSubmit =
@@ -311,7 +312,6 @@ export default function NewBookingPage() {
Boolean(destinationYardId) &&
!sameYard &&
Boolean(tradeDirection) &&
scheduleSatisfied &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
@@ -473,25 +473,25 @@ export default function NewBookingPage() {
</Group>
{hasBookableSchedules ? (
<Select
label="Train schedule"
label="Train schedule (optional)"
placeholder={
originYardId && destinationYardId
? "Select an open schedule on this route"
? "Leave blank to let the batch engine assign a train"
: "Pick origin & destination first"
}
data={scheduleOptions}
value={trainScheduleId}
onChange={setTrainScheduleId}
searchable
required
clearable
disabled={!originYardId || !destinationYardId || schedulesLoading}
nothingFoundMessage="No open schedules on this route"
description="The booking will be batched against this schedule once its contract is signed."
description="Optional: pin to a specific train. Otherwise the booking joins the day pool and the engine assigns a train by priority."
/>
) : originYardId && destinationYardId ? (
<Text size="sm" c="dimmed">
No open train schedule on this route set a preferred departure below. Staff can
link a schedule later.
No open train schedule on this route set a preferred departure below. The batch
engine assigns a train on that day, or staff can pin one later.
</Text>
) : null}
<Group grow align="flex-end">

View File

@@ -100,6 +100,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
},
PAYMENTS: {

View File

@@ -143,7 +143,6 @@ function mapBookingToFormValues(
scheduledDate: booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: "",
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
notes: "",
containers: [],
} as BookingFormInputValues;
@@ -410,7 +409,8 @@ export default function EditBookingPage() {
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: undefined,
trainScheduleId: data.trainScheduleId || undefined,
// Day-level pool: the customer edits only the day; the engine assigns the
// train, so trainScheduleId is not sent.
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,

View File

@@ -279,7 +279,8 @@ export default function NewBookingPage() {
destinationYardId: data.destinationYard,
tradeDirection: direction!,
cargoTypeId,
trainScheduleId: data.trainScheduleId,
// Day-level pool: the customer picks only a day (scheduledDate); the batch
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
allowConsolidation: data.consolidationEnabled,

View File

@@ -113,8 +113,9 @@ export const bookingFormSchema = z
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
// Day-level pool: the customer selects only a DAY. The batch engine assigns
// the specific train later, so no trainScheduleId is collected here.
scheduledDate: z.string().min(1, "Select a shipment date."),
trainScheduleId: z.string().min(1, "Select a shipment date."),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
cargoTypePath: z.array(z.string()).default([]),
@@ -236,7 +237,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
destinationYard: "",
shippingLine: "",
scheduledDate: "",
trainScheduleId: "",
cargoWeight: "",
cargoTypePath: [],
cargoFreeText: "",
@@ -272,7 +272,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"containers",
"consolidationEnabled",
],
5: ["scheduledDate", "trainScheduleId"],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
};

View File

@@ -5,10 +5,9 @@ import {
Button,
Card,
Group,
Modal,
Stack,
Text,
useMantineTheme
useMantineTheme,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
@@ -46,17 +45,15 @@ interface DayData {
isToday: boolean;
isCurrentMonth: boolean;
isSelectedDate: boolean;
schedules: Freight.BookableScheduleItem[];
hasSchedule: boolean;
/** True when the route has at least one departure on this day. */
hasDeparture: boolean;
}
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
const selectedDate = form.watch("scheduledDate");
const selectedScheduleId = form.watch("trainScheduleId");
const originYardId = form.watch("originYard");
const destinationYardId = form.watch("destinationYard");
const cargoType = form.watch("cargoType");
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
[referenceData, destinationYardId],
);
const { data: bookableSchedules } = useQuery(
api.bookings.getBookableSchedules.queryOptions({
// Day-level pool: the customer picks a DAY, not a train. We only fetch which
// days have a departure — no capacity, no per-train detail. The batch engine
// assigns the train later, distributing the day's pool by priority.
const { data: availableDays } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
// Group all schedules per date — multiple departures per day are allowed.
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
// match the format used by the calendar day keys.
const schedulesByDate = useMemo(() => {
const map = new Map<string, Freight.BookableScheduleItem[]>();
if (bookableSchedules) {
for (const s of bookableSchedules) {
const dateKey = s.scheduleDate.slice(0, 10);
const existing = map.get(dateKey) ?? [];
map.set(dateKey, [...existing, s]);
}
}
return map;
}, [bookableSchedules]);
const selectedSchedule = useMemo(
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
[bookableSchedules, selectedScheduleId],
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const availableCount = useMemo(() => {
let count = 0;
schedulesByDate.forEach((schedules) => {
if (schedules.some((s) => s.remainingWagons > 0)) count++;
});
return count;
}, [schedulesByDate]);
const days = useMemo((): DayData[] => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
const schedules = schedulesByDate.get(dateString) ?? [];
return {
day: date.getDate(),
dateString,
isToday: isToday(date),
isCurrentMonth: isSameMonth(date, currentDate),
isSelectedDate: selectedDate === dateString,
schedules,
hasSchedule: schedules.length > 0,
hasDeparture: departureDays.has(dateString),
};
});
}, [currentDate, schedulesByDate, selectedDate]);
}, [currentDate, departureDays, selectedDate]);
const availableCount = useMemo(
() => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
[days],
);
const cargoSummary = useMemo(() => {
if (!cargoType) return "Not selected";
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const weeksCount = Math.ceil(days.length / 7);
const handleDayClick = (day: DayData) => {
if (day.schedules.length > 1) {
setSelectedDayForModal(day);
} else if (day.schedules.length === 1) {
form.setValue("scheduledDate", day.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", day.schedules[0].id, {
shouldValidate: true,
});
}
};
const handleSelectScheduleFromModal = (scheduleId: string) => {
if (selectedDayForModal) {
form.setValue("scheduledDate", selectedDayForModal.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", scheduleId, {
shouldValidate: true,
});
setSelectedDayForModal(null);
}
if (!day.hasDeparture) return;
// Record only the day — no specific train is chosen.
form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
};
return (
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<Stack gap={14} px={24} py={18}>
<Text fz={13} fw={600} c="edr-text.0">
{originYardId && destinationYardId
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
: "Select origin and destination to see available departures"}
</Text>
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
}}
>
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
<DayCell
key={di}
day={d}
onDayClick={handleDayClick}
/>
<DayCell key={di} day={d} onDayClick={handleDayClick} />
))}
</Box>
))}
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
value={cargoSummary}
/>
{selectedSchedule && selectedDate && (
{selectedDate && (
<Box
p={14}
style={{
@@ -328,28 +284,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
c="edr-green.7"
style={{ letterSpacing: "0.08em" }}
>
SELECTED DEPARTURE
SELECTED DAY
</Text>
</Group>
<Text fw={800} fz={16} c="edr-text.0">
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Train
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
</Text>
</Group>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Wagons available
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
</Text>
</Group>
<Text fz={12.5} c="edr-muted">
Your train is confirmed by our freight desk after booking.
</Text>
</Stack>
</Box>
)}
@@ -380,154 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Stack>
</Box>
</Stack>
{/* ── Schedule Selection Modal ──────────────────────────── */}
<Modal
opened={!!selectedDayForModal}
onClose={() => setSelectedDayForModal(null)}
centered
size={520}
radius={18}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
>
{/* Header */}
<Box
px={24}
py={20}
style={{
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
}}
>
<Group gap={7} align="center" mb={6}>
<CalendarIcon size={15} color="#9FE9CC" />
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
Available departures
</Text>
</Group>
<Text fw={800} fz={19} c="#fff">
{selectedDayForModal
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
: ""}
</Text>
<Text fz={12.5} c="#A9BBCB" mt={2}>
{selectedDayForModal?.schedules.length ?? 0} train
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
{originName} {destinationName}
</Text>
</Box>
{/* Schedule list */}
<Stack gap={12} p={24}>
{selectedDayForModal?.schedules.map((schedule) => {
const remaining = schedule.remainingWagons;
const max = schedule.maxWagons || 1;
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
const isSelected = schedule.id === selectedScheduleId;
return (
<Box
key={schedule.id}
role="button"
tabIndex={0}
onClick={() => handleSelectScheduleFromModal(schedule.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelectScheduleFromModal(schedule.id);
}
}}
style={{
cursor: "pointer",
borderRadius: 14,
padding: 16,
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
boxShadow: isSelected
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
: "0 1px 2px rgba(16,24,40,0.04)",
transition: "all 150ms ease",
}}
>
<Group gap={14} wrap="nowrap" align="center">
<Box
style={{
width: 50,
height: 50,
flexShrink: 0,
borderRadius: 13,
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][6]} />
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text fw={800} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
<Text fz={12.5} c="edr-muted">
{schedule.trainNumber
? `Train ${schedule.trainNumber}`
: `#${schedule.id.slice(0, 6)}`}
</Text>
</Group>
{/* capacity bar */}
<Box mt={8}>
<Group justify="space-between" mb={4}>
<Text fz={11} fw={600} c="edr-muted">
{remaining} / {max} wagons free
</Text>
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
{pct}%
</Text>
</Group>
<Box
style={{
height: 6,
borderRadius: 999,
background: "#EEF2F6",
overflow: "hidden",
}}
>
<Box
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 999,
background:
pct > 25
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
: "#F2A516",
}}
/>
</Box>
</Box>
</Box>
<Box
style={{
width: 26,
height: 26,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
}}
>
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
</Box>
</Group>
</Box>
);
})}
</Stack>
</Modal>
</Group>
);
}
@@ -537,7 +332,7 @@ interface DayCellProps {
onDayClick: (day: DayData) => void;
}
function DayCell({ day: d, onDayClick, }: DayCellProps) {
function DayCell({ day: d, onDayClick }: DayCellProps) {
const theme = useMantineTheme();
if (!d.isCurrentMonth) {
@@ -561,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
const cellBg = d.isSelectedDate
? theme.colors["edr-soft"][0]
: d.hasSchedule
: d.hasDeparture
? "#FFFFFF"
: "transparent";
const cellBorder = d.isSelectedDate
? `2px solid ${theme.colors["edr-green"][5]}`
: d.hasSchedule
: d.hasDeparture
? `1px solid ${theme.colors["edr-border"][0]}`
: "none";
return (
<Box
onClick={() => d.hasSchedule && onDayClick(d)}
onClick={() => d.hasDeparture && onDayClick(d)}
style={{
height: 92,
borderRadius: theme.radius.md,
@@ -584,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
display: "flex",
flexDirection: "column",
gap: 4,
cursor: d.hasSchedule ? "pointer" : "default",
cursor: d.hasDeparture ? "pointer" : "default",
transition: "all 150ms ease",
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
boxShadow: d.hasDeparture && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
}}
onMouseEnter={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
}}
onMouseLeave={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
@@ -610,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
c={
d.isToday && !d.isSelectedDate
? "edr-green.6"
: d.hasSchedule
: d.hasDeparture
? "edr-text.0"
: "edr-muted"
}
@@ -635,50 +430,24 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
justifyContent: "center",
}}
>
<Check
size={14}
color="white"
strokeWidth={3}
/>
<Check size={14} color="white" strokeWidth={3} />
</Box>
)}
</Group>
{/* Availability marker — a dot + count, never the schedule list itself. */}
{d.hasSchedule && (
{/* Availability marker — a single dot for days that have a departure.
No counts or capacity are shown: it's a day-level pool. */}
{d.hasDeparture && (
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
<Group
gap={6}
align="center"
wrap="nowrap"
px={9}
py={4}
<Box
style={{
borderRadius: 999,
backgroundColor: d.isSelectedDate
? "#fff"
: theme.colors["edr-soft"][0],
border: `1px solid ${
d.isSelectedDate
? theme.colors["edr-green"][2]
: "transparent"
}`,
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
}}
>
<Box
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
}}
/>
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
</Text>
</Group>
/>
</Box>
)}
</Box>

View File

@@ -326,10 +326,6 @@ export function Step8Review({
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Shipment date" value={scheduleLabel} />
<DetailRow
label="Train schedule"
value={values.trainScheduleId ? "Selected" : "—"}
/>
</OverviewSection>
<OverviewSection
@@ -444,8 +440,8 @@ export function Step8Review({
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate && values.trainScheduleId)}
label="Schedule selected"
done={Boolean(values.scheduledDate)}
label="Shipment day selected"
/>
<ReadinessItem
done={

View File

@@ -220,6 +220,13 @@ export const api = {
>("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
),
getAvailableDays: endpoint<
{ originYardId?: string; destinationYardId?: string },
string[]
>("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
},
payments: {

View File

@@ -195,4 +195,18 @@ export const bookingsService = {
);
return data.data;
},
/**
* Day-level pool: the days that have a departure on the route. The customer
* picks a day; the engine assigns the train. No capacity is returned.
*/
getAvailableDays: async (
query: Freight.AvailableDaysQuery = {},
): Promise<string[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
{ params: query },
);
return (data.data as Freight.AvailableDaysResponse).days;
},
};