mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
implement intercity booking management and booking window websocket integration
This commit is contained in:
@@ -32,6 +32,7 @@ const DEFAULTS = {
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
};
|
||||
|
||||
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||||
@@ -53,6 +54,7 @@ interface FormState {
|
||||
docReviewMinutes: number | "";
|
||||
paymentWindowMinutes: number | "";
|
||||
importWindowLeadDays: number | "";
|
||||
exportBookingLeadHours: number | "";
|
||||
}
|
||||
|
||||
function parseError(error: unknown, fallback: string): string {
|
||||
@@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({
|
||||
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
|
||||
importWindowLeadDays:
|
||||
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
|
||||
exportBookingLeadHours:
|
||||
r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours,
|
||||
});
|
||||
}, [opened, schedule]);
|
||||
|
||||
@@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({
|
||||
const doc = Number(form.docReviewMinutes);
|
||||
const pay = Number(form.paymentWindowMinutes);
|
||||
const lead = Number(form.importWindowLeadDays);
|
||||
const exportLead = Number(form.exportBookingLeadHours);
|
||||
const leadInvalid = isExport
|
||||
? form.exportBookingLeadHours === "" ||
|
||||
!Number.isFinite(exportLead) ||
|
||||
exportLead < 1
|
||||
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
|
||||
if (
|
||||
form.windowDurationHours === "" ||
|
||||
form.docReviewMinutes === "" ||
|
||||
form.paymentWindowMinutes === "" ||
|
||||
form.importWindowLeadDays === "" ||
|
||||
!Number.isFinite(duration) ||
|
||||
!Number.isFinite(doc) ||
|
||||
!Number.isFinite(pay) ||
|
||||
!Number.isFinite(lead)
|
||||
leadInvalid
|
||||
) {
|
||||
toast({
|
||||
title: "Fill every field before saving",
|
||||
@@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({
|
||||
windowDurationHours: duration,
|
||||
docReviewMinutes: doc,
|
||||
paymentWindowMinutes: pay,
|
||||
importWindowLeadDays: lead,
|
||||
...(isExport
|
||||
? { exportBookingLeadHours: exportLead }
|
||||
: { importWindowLeadDays: lead }),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({
|
||||
<Stack gap="lg">
|
||||
{isExport ? (
|
||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||
Export schedules use a single FCFS lead window — the daily desk
|
||||
hours below don't apply, only the lead time does.
|
||||
Export schedules use a single first-come-first-served window: it
|
||||
opens the export lead time before departure — shifted to the next
|
||||
desk opening if that lands outside desk hours — and stays open
|
||||
until departure. Cycle timing below doesn't apply.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({
|
||||
}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<Select
|
||||
label="Closes"
|
||||
@@ -275,7 +287,6 @@ export default function BookingWindowSettingsModal({
|
||||
}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
disabled={isExport}
|
||||
/>
|
||||
</Group>
|
||||
{isOvernight && !is24h ? (
|
||||
@@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({
|
||||
color="grape"
|
||||
label="Run 24 hours a day (never pause overnight)"
|
||||
checked={is24h}
|
||||
disabled={isExport}
|
||||
onChange={(e) => {
|
||||
const checked = e.currentTarget.checked;
|
||||
setForm((f) => {
|
||||
@@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!isExport ? (
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
A not-yet-full train pauses at the close hour and resumes the next
|
||||
morning at the open hour, every day until it fills or departs.
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
{isExport
|
||||
? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead."
|
||||
: "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
@@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({
|
||||
<Divider />
|
||||
|
||||
{/* ── Lead time ────────────────────────────────────────────────── */}
|
||||
<NumberInput
|
||||
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
|
||||
description={
|
||||
isExport
|
||||
? "How many days before departure export booking opens"
|
||||
: "How many days before departure the booking window starts"
|
||||
}
|
||||
value={form.importWindowLeadDays}
|
||||
onChange={(v) =>
|
||||
setForm(
|
||||
(f) =>
|
||||
f && {
|
||||
...f,
|
||||
importWindowLeadDays: v === "" ? "" : Number(v),
|
||||
},
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
clampBehavior="none"
|
||||
allowDecimal={false}
|
||||
/>
|
||||
{isExport ? (
|
||||
<NumberInput
|
||||
label="Export booking lead (hours)"
|
||||
description="How many hours before departure the export booking window opens"
|
||||
value={form.exportBookingLeadHours}
|
||||
onChange={(v) =>
|
||||
setForm(
|
||||
(f) =>
|
||||
f && {
|
||||
...f,
|
||||
exportBookingLeadHours: v === "" ? "" : Number(v),
|
||||
},
|
||||
)
|
||||
}
|
||||
min={1}
|
||||
clampBehavior="none"
|
||||
allowDecimal={false}
|
||||
/>
|
||||
) : (
|
||||
<NumberInput
|
||||
label="Window lead (days)"
|
||||
description="How many days before departure the booking window starts"
|
||||
value={form.importWindowLeadDays}
|
||||
onChange={(v) =>
|
||||
setForm(
|
||||
(f) =>
|
||||
f && {
|
||||
...f,
|
||||
importWindowLeadDays: v === "" ? "" : Number(v),
|
||||
},
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
clampBehavior="none"
|
||||
allowDecimal={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
IntercityBookingRow,
|
||||
IntercityCapacity,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
return message || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
||||
}
|
||||
|
||||
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
||||
if (!capacity) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
Capacity unknown — schedule has no locomotive/train set yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
|
||||
{fmt(capacity.wagons)} wagons free
|
||||
</Badge>
|
||||
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
|
||||
{fmt(capacity.weightTons)} t free
|
||||
</Badge>
|
||||
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
|
||||
{fmt(capacity.lengthMeters)} m free
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedCells({ need }: { need: IntercityCapacity | null }) {
|
||||
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
||||
return (
|
||||
<>
|
||||
<Table.Td>{fmt(need.wagons)}</Table.Td>
|
||||
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
||||
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CorridorCell({ row }: { row: IntercityBookingRow }) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">{row.origin}</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm">{row.destination}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercity ride-along desk for one import/export schedule: waiting intercity
|
||||
* bookings whose corridor lies on this train's route, checked against the
|
||||
* remaining wagon/weight/length budget. Accepting opens the customer's pay
|
||||
* window; after payment the booking is allocated. Loading/unloading is
|
||||
* confirmed manually when the train is physically at the booking's origin /
|
||||
* destination yard (the server validates against recorded checkpoints).
|
||||
*/
|
||||
export function IntercityRideAlongPanel({
|
||||
scheduleId,
|
||||
direction,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
direction: string | null | undefined;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const candidatesQuery = useQuery(
|
||||
api.trainScheduling.intercityCandidates.queryOptions({
|
||||
input: { scheduleId },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||
});
|
||||
|
||||
const accept = useMutation(
|
||||
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setSelected([]);
|
||||
void invalidate();
|
||||
if (result.accepted.length > 0) {
|
||||
toast({
|
||||
title: `${result.accepted.length} intercity booking(s) accepted`,
|
||||
description: "Customers have been asked to pay.",
|
||||
});
|
||||
}
|
||||
for (const r of result.rejected) {
|
||||
toast({
|
||||
title: "Booking skipped",
|
||||
description: r.reason,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Accept failed",
|
||||
description: parseError(err, "Could not accept intercity bookings"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadIntercityBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
toast({ title: "Cargo loaded" });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Load failed",
|
||||
description: parseError(err, "Could not confirm loading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const unload = useMutation(
|
||||
api.trainScheduling.unloadIntercityBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
toast({ title: "Cargo unloaded — booking completed" });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Unload failed",
|
||||
description: parseError(err, "Could not confirm unloading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Intercity bookings only ride import/export trains.
|
||||
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
|
||||
|
||||
const data = candidatesQuery.data;
|
||||
const candidates = data?.candidates ?? [];
|
||||
const accepted = data?.accepted ?? [];
|
||||
|
||||
if (candidatesQuery.isLoading) {
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading intercity ride-along bookings…
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (candidates.length === 0 && accepted.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
<TrainFront size={18} />
|
||||
<Text fw={700}>Intercity ride-along</Text>
|
||||
</Group>
|
||||
<CapacityBadges capacity={data?.remaining ?? null} />
|
||||
</Group>
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Waiting intercity bookings whose corridor lies on this train's
|
||||
route. Accepting opens the customer's payment window against the
|
||||
free capacity above.
|
||||
</Text>
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={36} />
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Corridor</Table.Th>
|
||||
<Table.Th>Wagons</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Length</Table.Th>
|
||||
<Table.Th>Fits</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{candidates.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) =>
|
||||
setSelected((prev) =>
|
||||
e.currentTarget.checked
|
||||
? [...prev, row.id]
|
||||
: prev.filter((id) => id !== row.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment && (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
GOV
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<CorridorCell row={row} />
|
||||
</Table.Td>
|
||||
<NeedCells need={row.need} />
|
||||
<Table.Td>
|
||||
{row.fits ? (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
Fits
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
No room
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="xs"
|
||||
color="edr-green"
|
||||
loading={accept.isPending}
|
||||
disabled={selected.length === 0}
|
||||
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
|
||||
>
|
||||
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{accepted.length > 0 && (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
On this train
|
||||
</Text>
|
||||
<Table.ScrollContainer minWidth={680}>
|
||||
<Table verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Corridor</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{accepted.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<CorridorCell row={row} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{row.status === "PAID" && (
|
||||
<Tooltip label="Train must be at the booking's origin yard">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={load.isPending}
|
||||
onClick={() =>
|
||||
load.mutate({ scheduleId, bookingId: row.id })
|
||||
}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.status === "IN_TRANSIT" && (
|
||||
<Tooltip label="Train must be at the booking's destination yard">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={unload.isPending}
|
||||
onClick={() =>
|
||||
unload.mutate({ scheduleId, bookingId: row.id })
|
||||
}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{candidatesQuery.isError && (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user