mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user