Enhance overview and train scheduling features

- Updated OverviewContractsTabPanel to include a new donut chart for freight type distribution.
- Modified OverviewOperationsTabPanel to improve data visualization with additional charts and refactored data handling.
- Introduced CreateScheduleWindowFields component for configuring booking windows in train scheduling.
- Added new API endpoints for allocation candidates and booking allocation in trainScheduling.service.
- Enhanced BookingRequestsPage to support allocation of paid bookings with a modal for selecting alternative dates.
- Updated QUERY_KEYS and URLS constants to accommodate new operations and features.
- Improved type definitions for overview and train scheduling to support new functionalities.
This commit is contained in:
Marshal
2026-08-03 21:06:59 +00:00
parent 488c2465be
commit e68bdb7a1a
30 changed files with 1580 additions and 82 deletions

View File

@@ -4,7 +4,9 @@ import {
Box,
Button,
Card,
Checkbox,
Group,
Modal,
MultiSelect,
Select,
Stack,
@@ -50,7 +52,10 @@ import {
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { AllocationCandidate } from "@/types/trainScheduling";
import type { BookingListRow } from "@/types/booking";
import { useToast } from "@/hooks/use-toast";
import {
Badge,
DataTable,
@@ -155,6 +160,15 @@ export default function BookingRequestsPage() {
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
// Paid bookings with no train attached (staff removed them or a sweep
// detached them) — the queue the per-row Allocate action works through.
const [paidUnallocated, setPaidUnallocated] = useState(false);
const [allocatingId, setAllocatingId] = useState<string | null>(null);
const [otherDayModal, setOtherDayModal] = useState<{
booking: BookingListRow;
candidates: AllocationCandidate[];
} | null>(null);
const { toast } = useToast();
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
@@ -187,6 +201,10 @@ export default function BookingRequestsPage() {
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
// Wins over the payment-status select — the queue is by definition PAID.
...(paidUnallocated
? { paymentStatus: "PAID", assignedToSchedule: "false" as const }
: {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
@@ -208,6 +226,7 @@ export default function BookingRequestsPage() {
directionFilter,
freightTypeFilter,
paymentStatusFilter,
paidUnallocated,
ownershipFilter,
originYardFilter,
destinationYardFilter,
@@ -251,6 +270,7 @@ export default function BookingRequestsPage() {
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(paymentStatusFilter ? 1 : 0) +
(paidUnallocated ? 1 : 0) +
(ownershipFilter ? 1 : 0) +
(originYardFilter ? 1 : 0) +
(destinationYardFilter ? 1 : 0) +
@@ -263,6 +283,7 @@ export default function BookingRequestsPage() {
setDirectionFilter(null);
setFreightTypeFilter(null);
setPaymentStatusFilter(null);
setPaidUnallocated(false);
setOwnershipFilter(null);
setOriginYardFilter(null);
setDestinationYardFilter(null);
@@ -301,6 +322,67 @@ export default function BookingRequestsPage() {
[navigate],
);
// One click: same-day fit → allocate straight away. No same-day fit but a
// train on another date fits → let staff pick it (customer is notified of
// the date change by the API). Nothing fits → say so.
const handleAllocatePaid = useCallback(
async (row: BookingListRow) => {
setAllocatingId(row.id);
try {
const candidates =
await trainSchedulingService.getAllocationCandidates(row.id);
if (candidates.sameDay.length > 0) {
const target = candidates.sameDay[0];
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
toast({
title: `Allocated ${row.reference}`,
description: `Placed on ${target.reference ?? "train"} departing ${formatDate(target.scheduledDepartureDate)}.`,
});
void refetch();
} else if (candidates.otherDays.length > 0) {
setOtherDayModal({ booking: row, candidates: candidates.otherDays });
} else {
toast({
title: "No fitting train",
description:
"No open schedule covers this booking's route with enough capacity.",
variant: "destructive",
});
}
} catch {
toast({ title: "Allocation failed", variant: "destructive" });
} finally {
setAllocatingId(null);
}
},
[refetch, toast],
);
const handleAllocateOtherDay = useCallback(
async (candidate: AllocationCandidate) => {
if (!otherDayModal) return;
const { booking } = otherDayModal;
setAllocatingId(booking.id);
try {
await trainSchedulingService.allocatePaidBooking(
booking.id,
candidate.id,
);
toast({
title: `Allocated ${booking.reference}`,
description: `Placed on ${candidate.reference ?? "train"} departing ${formatDate(candidate.scheduledDepartureDate)}. Customer notified of the date change.`,
});
setOtherDayModal(null);
void refetch();
} catch {
toast({ title: "Allocation failed", variant: "destructive" });
} finally {
setAllocatingId(null);
}
},
[otherDayModal, refetch, toast],
);
const columns: ColumnDef<BookingListRow>[] = [
{
id: "booking",
@@ -435,13 +517,33 @@ export default function BookingRequestsPage() {
{
id: "actions",
size: 140,
cell: ({ row }) => (
<BookingActionsMenu
row={row.original}
variant="table"
onSuppressRowClick={suppressRowClick}
/>
),
cell: ({ row }) => {
const b = row.original;
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
return (
<Group gap="xs" wrap="nowrap">
{needsAllocation ? (
<Button
size="compact-xs"
color="edr-green"
loading={allocatingId === b.id}
onClick={(e) => {
e.stopPropagation();
suppressRowClick();
void handleAllocatePaid(b);
}}
>
Allocate
</Button>
) : null}
<BookingActionsMenu
row={b}
variant="table"
onSuppressRowClick={suppressRowClick}
/>
</Group>
);
},
},
];
@@ -640,6 +742,16 @@ export default function BookingRequestsPage() {
radius="lg"
style={{ minWidth: 180 }}
/>
<Checkbox
label="Paid, not allocated"
checked={paidUnallocated}
onChange={(e) => {
setPaidUnallocated(e.currentTarget.checked);
resetPage();
}}
radius="sm"
style={{ alignSelf: "center" }}
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
@@ -751,6 +863,42 @@ export default function BookingRequestsPage() {
</Card>
</Stack>
<Modal
opened={otherDayModal !== null}
onClose={() => setOtherDayModal(null)}
title="Allocate to another date"
centered
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
fits booking {otherDayModal?.booking.reference}. These trains on
other dates do the customer will be notified of the date change.
</Text>
{otherDayModal?.candidates.map((c) => (
<Group key={c.id} justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={500}>
{c.reference ?? "Train"}
</Text>
<Text size="xs" c="dimmed">
Departs {formatDate(c.scheduledDepartureDate)}
{c.direction ? ` · ${c.direction}` : ""}
</Text>
</div>
<Button
size="compact-sm"
color="edr-green"
loading={allocatingId === otherDayModal.booking.id}
onClick={() => void handleAllocateOtherDay(c)}
>
Allocate
</Button>
</Group>
))}
</Stack>
</Modal>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}

View File

@@ -6,12 +6,14 @@ import {
Button,
Card,
Checkbox,
Divider,
Group,
Menu,
Modal,
Select,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
@@ -46,6 +48,10 @@ import {
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
type WindowFormState,
} from "@/components/trainScheduling/CreateScheduleWindowFields";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
import {
@@ -59,6 +65,7 @@ import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
import type {
CreateScheduleWindowRulePayload,
FreightType,
TrainScheduleListFilters,
TrainScheduleListItem,
@@ -133,6 +140,10 @@ export default function TrainScheduleV2ListPage() {
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// Booking window for the schedule being created: off = inherit the live global
// rules (the default), on = the values in `windowForm` are frozen onto it.
const [configureWindow, setConfigureWindow] = useState(false);
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
@@ -535,6 +546,25 @@ export default function TrainScheduleV2ListPage() {
});
return;
}
// Only build the window override when the toggle is on — off means "inherit
// the global rules", which the API expresses as an absent windowRule.
let windowRule: CreateScheduleWindowRulePayload | undefined;
if (configureWindow) {
if (!windowForm) {
toast({ title: "Booking window settings are still loading", variant: "destructive" });
return;
}
const built = buildWindowRulePayload(
windowForm,
selectedRoute?.direction === "EXPORT",
);
if ("error" in built) {
toast({ title: built.error, variant: "destructive" });
return;
}
windowRule = built.payload;
}
try {
const created = await create.mutateAsync({
payload: {
@@ -542,11 +572,14 @@ export default function TrainScheduleV2ListPage() {
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
reverseWagonOrder,
...(windowRule ? { windowRule } : {}),
},
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setReverseWagonOrder(false);
setConfigureWindow(false);
setWindowForm(null);
setCreateOpen(false);
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
} catch (err) {
@@ -843,6 +876,22 @@ export default function TrainScheduleV2ListPage() {
checked={reverseWagonOrder}
onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)}
/>
<Divider />
<Switch
label="Configure booking window for this schedule"
description="Off, this train follows the global booking rules. On, the settings below are frozen onto it and a later global-rules change won't move them."
checked={configureWindow}
onChange={(e) => setConfigureWindow(e.currentTarget.checked)}
/>
{configureWindow ? (
<CreateScheduleWindowFields
isExport={selectedRoute?.direction === "EXPORT"}
form={windowForm}
onChange={setWindowForm}
/>
) : null}
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel