feat(train-scheduling): add day-level booking pool and available days query

This commit is contained in:
Marshal
2026-06-18 19:48:42 +00:00
parent 30bfc84f8f
commit be7c569363
4 changed files with 98 additions and 62 deletions

View File

@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/train-scheduling/available-days",
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
BATCH_BOARD: "/train-scheduling/batch-board",
BATCH_BOARD_DETAIL: (scheduleId: string) =>

View File

@@ -132,6 +132,29 @@ export const useBookableSchedules = (
enabled: Boolean(originYardId && destinationYardId),
});
/**
* Day-level pool: which days have an OPEN departure on the route. Staff pick a
* day (not a train) when creating a booking; the engine assigns the train.
*/
export const useAvailableDays = (
originYardId?: string | null,
destinationYardId?: string | null,
) =>
useQuery({
queryKey: [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"available-days",
originYardId ?? "",
destinationYardId ?? "",
],
queryFn: () =>
trainSchedulingService.getAvailableDays(
originYardId ?? undefined,
destinationYardId ?? undefined,
),
enabled: Boolean(originYardId && destinationYardId),
});
export const useTrainTrack = (id: string | undefined) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),

View File

@@ -44,7 +44,7 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
import { api } from "@/auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -194,9 +194,9 @@ export default function NewBookingPage() {
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
// Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
// container freight
@@ -232,34 +232,37 @@ export default function NewBookingPage() {
label: c.name || c.email || c.tin || c.id,
}));
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
// Day-level pool: fetch only the days that have a departure on the route (no
// train, no capacity). The batch engine assigns the train after booking.
const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
originYardId,
destinationYardId,
);
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
const dayOptions = (availableDays ?? []).map((day) => ({
value: day,
label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
}));
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
const hasAvailableDays = (availableDays ?? []).length > 0;
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
const effectiveDepartureIso = selectedSchedule
? new Date(selectedSchedule.scheduleDate).toISOString()
: scheduledDate
? new Date(scheduledDate).toISOString()
: "";
// The chosen day becomes the booking's scheduledDate (start of day, ISO).
const effectiveDepartureIso = scheduledDay
? new Date(`${scheduledDay}T00:00:00`).toISOString()
: "";
const yardRecords = refData?.yard ?? [];
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
// Reset the day when the route changes — available days depend on the route.
useEffect(() => {
setTrainScheduleId(null);
setScheduledDay(null);
}, [originYardId, destinationYardId]);
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
@@ -302,10 +305,9 @@ export default function NewBookingPage() {
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
// 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);
// Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
// the train afterwards (same flow as the customer portal).
const departureSatisfied = Boolean(scheduledDay);
const canSubmit =
Boolean(originYardId) &&
@@ -339,7 +341,7 @@ export default function NewBookingPage() {
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
originYardId,
destinationYardId,
trainScheduleId: trainScheduleId || undefined,
// Day-level pool: no trainScheduleId — the engine assigns the train.
serviceTypeId,
shippingLineId: shippingLineId || undefined,
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
@@ -464,36 +466,30 @@ export default function NewBookingPage() {
value={destinationYardId}
onChange={(v) => {
setDestinationYardId(v);
setTrainScheduleId(null);
setScheduledDay(null);
}}
searchable
disabled={isLoading}
error={sameYard ? "Same as origin" : undefined}
/>
</Group>
{hasBookableSchedules ? (
<Select
label="Train schedule (optional)"
placeholder={
originYardId && destinationYardId
? "Leave blank to let the batch engine assign a train"
: "Pick origin & destination first"
}
data={scheduleOptions}
value={trainScheduleId}
onChange={setTrainScheduleId}
searchable
clearable
disabled={!originYardId || !destinationYardId || schedulesLoading}
nothingFoundMessage="No open schedules on this route"
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. The batch
engine assigns a train on that day, or staff can pin one later.
</Text>
) : null}
<Select
label="Shipment day"
placeholder={
originYardId && destinationYardId
? "Select a day with a departure"
: "Pick origin & destination first"
}
data={dayOptions}
value={scheduledDay}
onChange={setScheduledDay}
searchable
disabled={!originYardId || !destinationYardId || daysLoading}
nothingFoundMessage={
hasAvailableDays ? "No match" : "No departures on this route"
}
description="Pick a day with a departure. The batch engine assigns the train by priority."
/>
<Group grow align="flex-end">
<Select
label="Service type"
@@ -528,21 +524,22 @@ export default function NewBookingPage() {
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
<Group grow align="flex-start">
{selectedSchedule ? (
<TextInput
label="Departure"
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
readOnly
description="Taken from the selected train schedule"
/>
) : (
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
)}
<TextInput
label="Shipment day"
value={
scheduledDay
? new Date(`${scheduledDay}T00:00:00`).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
: ""
}
placeholder="Pick a day in the Route section"
readOnly
description="The engine assigns the train on this day"
/>
<Select
label="Payment currency"
data={[

View File

@@ -109,6 +109,21 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/**
* Day-level pool: the days that have an OPEN departure on the route. Staff pick
* a day; the batch engine assigns the train. No capacity is returned.
*/
getAvailableDays: async (
originYardId?: string,
destinationYardId?: string,
): Promise<string[]> => {
const response = await client.get<{ days: string[] }>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
{ params: { originYardId, destinationYardId } },
);
return unwrap(response.data).days;
},
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
const response = await client.post<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),