mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import { ArrowLeft, Train } from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||
import {
|
||||
autoFillPlacements,
|
||||
mergePlacementsWithSaved,
|
||||
placementsFromScheduleWagons,
|
||||
validateLocalPlacements,
|
||||
} from "@/components/trainScheduling/containerPlacement.util";
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||
import {
|
||||
PreviewSummary,
|
||||
ScheduleWarningsAlert,
|
||||
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
||||
import {
|
||||
FreightTypeBadge,
|
||||
ScheduleStatusBadge,
|
||||
} from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import { SchedulingWorkflowHeader } from "@/components/trainScheduling/SchedulingWorkflowHeader";
|
||||
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
|
||||
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
import {
|
||||
useEligibleBookings,
|
||||
useScheduleDetail,
|
||||
useScheduleMutations,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
FreightType,
|
||||
TrainSchedulePreviewResponse,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||
const message = data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
const violations = data?.violations;
|
||||
if (Array.isArray(violations)) return violations.join(", ");
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export default function TrainScheduleV2DetailPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const { toast } = useToast();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
||||
const [forceAssign, setForceAssign] = useState(false);
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||
const autoPreviewedRef = useRef(false);
|
||||
|
||||
const detailQuery = useScheduleDetail(scheduleId);
|
||||
const schedule = detailQuery.data;
|
||||
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
||||
|
||||
const eligibleFilters = useMemo(
|
||||
() =>
|
||||
schedule
|
||||
? {
|
||||
originStationId: schedule.originStation?.id,
|
||||
destinationStationId: schedule.destinationStation?.id,
|
||||
}
|
||||
: undefined,
|
||||
[schedule],
|
||||
);
|
||||
|
||||
const eligibleFreightType =
|
||||
freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined;
|
||||
|
||||
const eligibleQuery = useEligibleBookings(
|
||||
eligibleFilters,
|
||||
Boolean(schedule),
|
||||
eligibleFreightType,
|
||||
);
|
||||
const { preview, assign, unassign, finalize, dispatch } = useScheduleMutations(scheduleId);
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||
[schedule?.bookings],
|
||||
);
|
||||
|
||||
const allSelectedIds = useMemo(() => {
|
||||
const merged = new Set([...assignedIds, ...selectedBookingIds]);
|
||||
return [...merged];
|
||||
}, [assignedIds, selectedBookingIds]);
|
||||
|
||||
const containerUnits = previewResult?.containerUnits ?? [];
|
||||
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
||||
const hasContainerStep = useMemo(
|
||||
() =>
|
||||
shouldShowContainerPlacementStep({
|
||||
containerUnitCount: containerUnits.length,
|
||||
scheduleFreightType: freightType,
|
||||
bookingFreightTypes: [
|
||||
...(schedule?.bookings ?? []).map((b) => b.freightType),
|
||||
...(eligibleQuery.data?.items ?? [])
|
||||
.filter((item) => allSelectedIds.includes(item.id))
|
||||
.map((item) => item.freightType),
|
||||
],
|
||||
}),
|
||||
[
|
||||
allSelectedIds,
|
||||
containerUnits.length,
|
||||
eligibleQuery.data?.items,
|
||||
freightType,
|
||||
schedule?.bookings,
|
||||
],
|
||||
);
|
||||
|
||||
const displayWagonPlan = useMemo(() => {
|
||||
if (previewResult?.wagonPlan?.length) return previewResult.wagonPlan;
|
||||
if (schedule?.trainSet?.wagons?.length) return schedule.trainSet.wagons;
|
||||
return [];
|
||||
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
|
||||
|
||||
const runPreview = useCallback(
|
||||
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
|
||||
if (!schedule || !scheduleId) return null;
|
||||
if (!allSelectedIds.length) {
|
||||
if (!options?.silent) {
|
||||
toast({ title: "Select at least one booking", variant: "destructive" });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const originStationId = schedule.originStation?.id;
|
||||
const destinationStationId = schedule.destinationStation?.id;
|
||||
if (!originStationId || !destinationStationId) {
|
||||
if (!options?.silent) {
|
||||
toast({ title: "Schedule missing origin or destination", variant: "destructive" });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const result = await preview.mutateAsync({
|
||||
freightType,
|
||||
payload: {
|
||||
bookingIds: allSelectedIds,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
targetScheduleId: scheduleId,
|
||||
},
|
||||
});
|
||||
setPreviewResult(result);
|
||||
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
|
||||
const autoFilled = autoFillPlacements(
|
||||
result.containerUnits,
|
||||
result.containerSlotSequenceNos,
|
||||
);
|
||||
const saved = schedule.trainSet?.wagons
|
||||
? placementsFromScheduleWagons(schedule.trainSet.wagons)
|
||||
: [];
|
||||
setContainerPlacements(
|
||||
saved.length ? mergePlacementsWithSaved(autoFilled, saved) : autoFilled,
|
||||
);
|
||||
} else {
|
||||
setContainerPlacements([]);
|
||||
}
|
||||
if (!options?.silent) {
|
||||
if (!result.valid) {
|
||||
toast({ title: "Preview has violations", variant: "destructive" });
|
||||
} else if (options?.advanceStep !== false) {
|
||||
setActiveStep(1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!options?.silent) {
|
||||
toast({
|
||||
title: "Preview failed",
|
||||
description: parseError(err, "Could not preview"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[allSelectedIds, freightType, preview, schedule, scheduleId, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schedule || !scheduleId || autoPreviewedRef.current) return;
|
||||
if (!assignedIds.length) return;
|
||||
autoPreviewedRef.current = true;
|
||||
void runPreview({ silent: true, advanceStep: false });
|
||||
}, [assignedIds.length, runPreview, schedule, scheduleId]);
|
||||
|
||||
const savedPlacementsFromSchedule = useMemo(
|
||||
() =>
|
||||
schedule?.trainSet?.wagons
|
||||
? placementsFromScheduleWagons(schedule.trainSet.wagons)
|
||||
: [],
|
||||
[schedule?.trainSet?.wagons],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerUnits.length || !containerSlots.length) return;
|
||||
|
||||
setContainerPlacements((current) => {
|
||||
if (current.length && current.some((p) => p.containerNumber?.trim())) {
|
||||
return current;
|
||||
}
|
||||
const autoFilled = autoFillPlacements(containerUnits, containerSlots);
|
||||
if (savedPlacementsFromSchedule.length) {
|
||||
return mergePlacementsWithSaved(autoFilled, savedPlacementsFromSchedule);
|
||||
}
|
||||
if (current.length) return current;
|
||||
return autoFilled;
|
||||
});
|
||||
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schedule || !scheduleId) {
|
||||
return (
|
||||
<Text c="dimmed" py="xl">
|
||||
Schedule not found
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
|
||||
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
|
||||
const canDispatch = schedule.status === "SCHEDULED";
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!allSelectedIds.length) return;
|
||||
|
||||
if (hasContainerStep) {
|
||||
const issues = validateLocalPlacements(containerUnits, containerPlacements);
|
||||
if (issues.length) {
|
||||
toast({
|
||||
title: "Complete container assignments",
|
||||
description: issues.join(", "),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await assign.mutateAsync({
|
||||
id: scheduleId,
|
||||
freightType,
|
||||
payload: {
|
||||
bookingIds: allSelectedIds,
|
||||
forceAssign,
|
||||
containerPlacements: hasContainerStep ? containerPlacements : undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: "Bookings assigned — wagons auto-pinned" });
|
||||
const refreshed = await detailQuery.refetch();
|
||||
const saved = refreshed.data?.trainSet?.wagons
|
||||
? placementsFromScheduleWagons(refreshed.data.trainSet.wagons)
|
||||
: [];
|
||||
if (saved.length) {
|
||||
setContainerPlacements(saved);
|
||||
}
|
||||
autoPreviewedRef.current = false;
|
||||
setActiveStep(finalizeStep);
|
||||
if (result.deferredBookings?.length) {
|
||||
toast({
|
||||
title: "Partial assignment",
|
||||
description: `${result.deferredBookings.length} booking(s) deferred to next train`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Assign failed",
|
||||
description: parseError(err, "Could not assign"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnassign = async (bookingId: string) => {
|
||||
try {
|
||||
await unassign.mutateAsync({ id: scheduleId, bookingId });
|
||||
toast({ title: "Booking unassigned" });
|
||||
setSelectedBookingIds((ids) => ids.filter((id) => id !== bookingId));
|
||||
setPreviewResult(null);
|
||||
autoPreviewedRef.current = false;
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Unassign failed",
|
||||
description: parseError(err, "Could not unassign"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stepLabels = [
|
||||
"Bookings",
|
||||
"Wagon plan",
|
||||
...(hasContainerStep ? ["Containers"] : []),
|
||||
"Finalize",
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Button
|
||||
component={Link}
|
||||
to="/dashboard/operations/train-scheduling-v2"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
w="fit-content"
|
||||
>
|
||||
Back to schedules
|
||||
</Button>
|
||||
|
||||
<Card
|
||||
radius={schedulingWorkflow.card.radius}
|
||||
padding={schedulingWorkflow.card.padding}
|
||||
withBorder
|
||||
style={{
|
||||
background: "linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start">
|
||||
<Paper p="sm" radius="xl" bg="teal.1">
|
||||
<Train size={24} color="var(--mantine-color-teal-7)" />
|
||||
</Paper>
|
||||
<Stack gap={4}>
|
||||
<Title order={3}>{schedule.route?.name ?? "Train schedule"}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{schedule.originStation?.label ?? schedule.originStation?.code} →{" "}
|
||||
{schedule.destinationStation?.label ?? schedule.destinationStation?.code}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Departure {new Date(schedule.scheduledDepartureDate).toLocaleString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{schedule.status !== "DISPATCHED" ? (
|
||||
<Button variant="light" size="compact-sm" onClick={() => setMaintenanceOpen(true)}>
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
<Group gap="xl">
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Locomotive
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.trainSet?.locomotive?.code ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Bookings
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.bookings?.length ?? 0}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.trainSet?.wagonCount ?? displayWagonPlan.length} ·{" "}
|
||||
{schedule.trainSet?.totalWeightTons ?? 0}T
|
||||
</Text>
|
||||
</Stack>
|
||||
{previewResult ? (
|
||||
<Badge variant="light" color={previewResult.valid ? "green" : "red"}>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card radius={schedulingWorkflow.card.radius} padding={schedulingWorkflow.card.padding} withBorder>
|
||||
<Stack gap="lg">
|
||||
<SchedulingWorkflowHeader
|
||||
title="Scheduling workflow"
|
||||
subtitle={`${schedule.route?.name ?? "Train schedule"} · ${schedule.originStation?.code ?? ""} → ${schedule.destinationStation?.code ?? ""}`}
|
||||
activeStep={activeStep}
|
||||
totalSteps={stepLabels.length}
|
||||
stepLabel={stepLabels[activeStep] ?? ""}
|
||||
stepDescription={
|
||||
activeStep === 0
|
||||
? "Select & preview"
|
||||
: activeStep === 1
|
||||
? "Allocations"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "Map units"
|
||||
: "Depart"
|
||||
}
|
||||
stepIcon={
|
||||
activeStep === 0
|
||||
? "package"
|
||||
: activeStep === 1
|
||||
? "layout"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "container"
|
||||
: "check"
|
||||
}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
color={schedulingWorkflow.stepper.color}
|
||||
iconSize={schedulingWorkflow.stepper.iconSize}
|
||||
size={schedulingWorkflow.stepper.size}
|
||||
>
|
||||
<Stepper.Step label="Bookings" description="Select & preview">
|
||||
<Stack gap="md" mt="lg">
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(schedule.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allSelectedIds}
|
||||
onSelectionChange={(ids) => {
|
||||
const assigned = new Set(assignedIds);
|
||||
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
|
||||
}}
|
||||
assignedIds={assignedIds}
|
||||
freightType={freightType}
|
||||
canRemove={canModifyBookings}
|
||||
onRemove={handleUnassign}
|
||||
/>
|
||||
|
||||
{canEditBookings ? (
|
||||
<Group align="center" wrap="wrap">
|
||||
<Button
|
||||
variant="filled"
|
||||
loading={preview.isPending}
|
||||
onClick={() => void runPreview()}
|
||||
>
|
||||
Preview plan
|
||||
</Button>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold/overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wagon plan" description="Allocations">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!displayWagonPlan.length && !previewResult ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Run a preview from the Bookings step to generate the wagon plan.
|
||||
</Text>
|
||||
) : null}
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
|
||||
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
|
||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" onClick={() => setActiveStep(2)}>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={() => void runPreview()}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
{hasContainerStep ? (
|
||||
<Stepper.Step label="Containers" description="Map units">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
{canEditBookings ? (
|
||||
<Group>
|
||||
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
|
||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
) : null}
|
||||
|
||||
<Stepper.Step label="Finalize" description="Depart">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalize moves the schedule to SCHEDULED. Dispatch begins rail movement.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Group>
|
||||
{canFinalize ? (
|
||||
<Button
|
||||
color="green"
|
||||
loading={finalize.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await finalize.mutateAsync(scheduleId);
|
||||
toast({ title: "Schedule finalized" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Finalize failed",
|
||||
description: parseError(err, "Could not finalize"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
) : null}
|
||||
{canDispatch ? (
|
||||
<Button
|
||||
color="blue"
|
||||
loading={dispatch.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
toast({ title: "Train dispatched" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
description: parseError(err, "Could not dispatch"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Dispatch train
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{scheduleId ? (
|
||||
<RescheduleTrainDialog
|
||||
scheduleId={scheduleId}
|
||||
currentBookingIds={(schedule.bookings ?? []).map((b) => b.id)}
|
||||
opened={maintenanceOpen}
|
||||
onClose={() => setMaintenanceOpen(false)}
|
||||
onComplete={() => void detailQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Train } from "lucide-react";
|
||||
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import {
|
||||
FreightTypeBadge,
|
||||
ScheduleStatusBadge,
|
||||
} from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useRoutes } from "@/hooks/useRoutes";
|
||||
import {
|
||||
useAvailableLocomotives,
|
||||
useScheduleList,
|
||||
useScheduleMutations,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { FreightType, TrainScheduleListItem } from "@/types/trainScheduling";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export default function TrainScheduleV2ListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [freightFilter, setFreightFilter] = useState("ALL");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [locomotiveId, setLocomotiveId] = useState("");
|
||||
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const { create, cancel } = useScheduleMutations();
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return (schedulesQuery.data ?? []).filter((s) => {
|
||||
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
|
||||
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
s.trainNumber,
|
||||
s.routeName,
|
||||
s.origin,
|
||||
s.destination,
|
||||
s.locomotive?.code,
|
||||
s.freightType,
|
||||
s.status,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [schedulesQuery.data, search, statusFilter, freightFilter]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filtered.slice(start, start + pagination.pageSize);
|
||||
}, [filtered, pagination]);
|
||||
|
||||
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
{
|
||||
id: "date",
|
||||
header: "Departure",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatDate(row.original.scheduleDate),
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Route",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.routeName ?? "—",
|
||||
},
|
||||
{
|
||||
id: "corridor",
|
||||
header: "Corridor",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => `${row.original.origin ?? "—"} → ${row.original.destination ?? "—"}`,
|
||||
},
|
||||
{
|
||||
id: "freight",
|
||||
header: "Freight",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
|
||||
},
|
||||
{
|
||||
id: "loco",
|
||||
header: "Locomotive",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.locomotive?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "metrics",
|
||||
header: "Bookings / Wagons",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) =>
|
||||
`${row.original.bookingsCount} / ${row.original.wagonCount} · ${row.original.totalWeightTons}T`,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <ScheduleStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`)
|
||||
}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
loading={cancel.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancel.mutateAsync({
|
||||
id: row.original.id,
|
||||
freightType: row.original.freightType ?? "CONTAINER",
|
||||
});
|
||||
toast({ title: "Schedule cancelled" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Cancel failed",
|
||||
description: parseError(err, "Could not cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [navigate, cancel.isPending, toast]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!routeId || !scheduleDate || !locomotiveId) {
|
||||
toast({ title: "Select route, date, and locomotive", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await create.mutateAsync({
|
||||
payload: { routeId, scheduleDate, locomotiveId },
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
setCreateOpen(false);
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Create failed",
|
||||
description: parseError(err, "Could not create schedule"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const tableStatus = schedulesQuery.isLoading
|
||||
? "loading"
|
||||
: schedulesQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper
|
||||
p="lg"
|
||||
radius={schedulingWorkflow.card.radius}
|
||||
withBorder
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
|
||||
}}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
<ThemeIcon size={48} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
|
||||
<Train size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>Train Schedules</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Plan departures, allocate bookings, and dispatch trains across corridors.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search schedules…"
|
||||
addLabel="Create schedule"
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={statusFilter}
|
||||
onChange={(v) => v && setStatusFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
{ value: "DISPATCHED", label: "Dispatched" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
]}
|
||||
w={150}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={freightFilter}
|
||||
onChange={(v) => v && setFreightFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All freight" },
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
{ value: "MIXED", label: "Mixed" },
|
||||
]}
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{viewMode === "table" ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paged}
|
||||
status={tableStatus}
|
||||
emptyMessage="No train schedules found"
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filtered.length,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
table={table}
|
||||
pagination={footerPagination}
|
||||
options={{ labels: { items: "schedules" } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{!paged.length ? (
|
||||
<Text py="xl" ta="center" c="dimmed" size="sm">
|
||||
No train schedules found
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
|
||||
{paged.map((schedule) => (
|
||||
<Card key={schedule.id} radius="lg" padding="lg" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(schedule.scheduleDate)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.origin} → {schedule.destination}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.bookingsCount} bookings · {schedule.wagonCount} wagons
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||
}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={filtered.length}
|
||||
itemLabel="schedules"
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title={<Text fw={600}>Create train schedule</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Schedules support both container and bulk bookings once assigned.
|
||||
</Text>
|
||||
<Select
|
||||
label="Route"
|
||||
placeholder="Select route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
value={scheduleDate ? scheduleDate.slice(0, 16) : ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
setScheduleDate(raw ? new Date(raw).toISOString() : "");
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder="Select locomotive"
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" loading={create.isPending} onClick={handleCreate}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Card, Group, NumberInput, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
|
||||
|
||||
export default function TrainSchedulingGlobalRulesPage() {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const rules = await trainSchedulingService.getGlobalRules();
|
||||
setForm(rules);
|
||||
} catch {
|
||||
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [toast]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await trainSchedulingService.updateGlobalRules({
|
||||
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
|
||||
maxTrainWeightTons: Number(form.maxTrainWeightTons),
|
||||
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
|
||||
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
|
||||
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
|
||||
});
|
||||
setForm(updated);
|
||||
toast({ title: "Train scheduling rules saved" });
|
||||
} catch {
|
||||
toast({ title: "Failed to save rules", variant: "destructive" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={720}>
|
||||
<Stack gap={4}>
|
||||
<Title order={3}>Train scheduling rules</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Global limits applied when previewing and assigning bookings to trains.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Card radius="xl" padding="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label="Max train length (m)"
|
||||
description="Sum of all wagon lengths must not exceed this"
|
||||
value={form.maxTrainLengthMeters ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max train weight (T)"
|
||||
description="Total container and bulk cargo weight must not exceed this"
|
||||
value={form.maxTrainWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
value={form.maxWagonsPerTrain ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max 20ft container weight (T)"
|
||||
description="Each individual 20ft container gross weight limit"
|
||||
value={form.max20ftContainerWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftContainerWeightTons: Number(value),
|
||||
}))
|
||||
}
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max 20ft pair weight difference (T)"
|
||||
description="When two 20ft containers share a wagon, |weight1 − weight2| must not exceed this"
|
||||
value={form.max20ftPairWeightDiffTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftPairWeightDiffTons: Number(value),
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button color="teal" loading={saving} disabled={loading} onClick={() => void handleSave()}>
|
||||
Save rules
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user