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

@@ -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"