mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
945 lines
30 KiB
TypeScript
945 lines
30 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
ArrowLeft,
|
|
CalendarClock,
|
|
CheckCircle2,
|
|
Container as ContainerIcon,
|
|
Eye,
|
|
LayoutGrid,
|
|
Navigation,
|
|
Package,
|
|
Route as RouteIcon,
|
|
Send,
|
|
Train,
|
|
Weight,
|
|
} from "lucide-react";
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
Loader,
|
|
Paper,
|
|
RingProgress,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
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 } from "@/components/trainScheduling/ScheduleStatusBadge";
|
|
import {
|
|
RouteCorridor,
|
|
StatTile,
|
|
StatusPill,
|
|
scheduleBrand,
|
|
} from "@/components/trainScheduling/scheduleVisuals";
|
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
|
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
|
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
|
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
|
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(() => {
|
|
const savedWagons = schedule?.trainSet?.wagons ?? [];
|
|
// Map each slot to its reserved physical wagon number (from the wagon table) so the
|
|
// plan shows real wagon ids (e.g. WGN-DEMO-001) instead of generic "Wagon #1".
|
|
const physicalBySeq = new Map(
|
|
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
|
|
);
|
|
if (previewResult?.wagonPlan?.length) {
|
|
return previewResult.wagonPlan.map((slot) => ({
|
|
...slot,
|
|
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
|
|
}));
|
|
}
|
|
if (savedWagons.length) return savedWagons;
|
|
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 containerComplete =
|
|
hasContainerStep &&
|
|
containerUnits.length > 0 &&
|
|
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
|
|
const finalizeComplete = ["SCHEDULED", "DISPATCHED", "ARRIVED"].includes(
|
|
schedule.status,
|
|
);
|
|
|
|
const stepsMeta = [
|
|
{
|
|
key: "bookings",
|
|
icon: Package,
|
|
title: "Bookings",
|
|
subtitle: "Select cargo & preview the plan",
|
|
complete: Boolean(previewResult) || assignedIds.length > 0,
|
|
},
|
|
{
|
|
key: "wagon",
|
|
icon: LayoutGrid,
|
|
title: "Wagon plan",
|
|
subtitle: "Review generated allocations",
|
|
complete: displayWagonPlan.length > 0,
|
|
},
|
|
...(hasContainerStep
|
|
? [
|
|
{
|
|
key: "container",
|
|
icon: ContainerIcon,
|
|
title: "Containers",
|
|
subtitle: "Map units to wagon slots",
|
|
complete: containerComplete,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
key: "finalize",
|
|
icon: CheckCircle2,
|
|
title: "Finalize",
|
|
subtitle: "Lock the plan & dispatch",
|
|
complete: finalizeComplete,
|
|
},
|
|
];
|
|
const completedCount = stepsMeta.filter((s) => s.complete).length;
|
|
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
|
|
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
|
|
|
|
const renderStepRightSlot = (key: string) => {
|
|
if (key === "bookings") {
|
|
if (previewResult) {
|
|
return (
|
|
<Badge
|
|
variant="light"
|
|
color={previewResult.valid ? "green" : "red"}
|
|
radius="sm"
|
|
>
|
|
{previewResult.valid ? "Plan valid" : "Has issues"}
|
|
</Badge>
|
|
);
|
|
}
|
|
return allSelectedIds.length ? (
|
|
<Badge variant="light" color="green" radius="sm">
|
|
{allSelectedIds.length} selected
|
|
</Badge>
|
|
) : null;
|
|
}
|
|
if (key === "wagon" && displayWagonPlan.length) {
|
|
return (
|
|
<Badge variant="light" color="green" radius="sm">
|
|
{displayWagonPlan.length} wagons
|
|
</Badge>
|
|
);
|
|
}
|
|
if (key === "container" && containerUnits.length) {
|
|
return (
|
|
<Badge
|
|
variant="light"
|
|
color={containerComplete ? "green" : "yellow"}
|
|
radius="sm"
|
|
>
|
|
{containerUnits.length} units
|
|
</Badge>
|
|
);
|
|
}
|
|
if (key === "finalize") {
|
|
return <StatusPill status={schedule.status} />;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderStepBody = (key: string) => {
|
|
if (key === "bookings") {
|
|
return (
|
|
<Stack gap="md">
|
|
<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"
|
|
justify="space-between"
|
|
wrap="wrap"
|
|
gap="md"
|
|
p="sm"
|
|
style={{
|
|
borderRadius: 12,
|
|
background: "var(--mantine-color-gray-0)",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<Checkbox
|
|
label="Force assign (bypass hold / overweight warnings)"
|
|
checked={forceAssign}
|
|
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
|
size="sm"
|
|
/>
|
|
<Button
|
|
color="green"
|
|
radius="md"
|
|
leftSection={<Eye size={16} />}
|
|
loading={preview.isPending}
|
|
onClick={() => void runPreview()}
|
|
>
|
|
Preview plan
|
|
</Button>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
if (key === "wagon") {
|
|
return (
|
|
<Stack gap="md">
|
|
{!displayWagonPlan.length && !previewResult ? (
|
|
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
|
<Text size="sm" c="dimmed">
|
|
Run a preview from the Bookings step to generate the wagon plan.
|
|
</Text>
|
|
</Paper>
|
|
) : null}
|
|
<FleetAvailabilitySummary
|
|
fleetAvailability={previewResult?.fleetAvailability}
|
|
deferredBookings={previewResult?.deferredBookings}
|
|
/>
|
|
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
|
|
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
|
<Group>
|
|
{!hasContainerStep ? (
|
|
<Button
|
|
color="green"
|
|
radius="md"
|
|
loading={assign.isPending}
|
|
onClick={handleAssign}
|
|
>
|
|
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
color="green"
|
|
radius="md"
|
|
rightSection={<ContainerIcon size={16} />}
|
|
onClick={() => setActiveStep(2)}
|
|
>
|
|
Continue to containers
|
|
</Button>
|
|
)}
|
|
<Button variant="default" radius="md" onClick={() => void runPreview()}>
|
|
Refresh preview
|
|
</Button>
|
|
</Group>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
if (key === "container") {
|
|
return (
|
|
<Stack gap="md">
|
|
{!containerUnits.length ? (
|
|
<Paper p="md" radius="lg" 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"
|
|
radius="md"
|
|
loading={assign.isPending}
|
|
onClick={handleAssign}
|
|
>
|
|
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
onClick={() => setActiveStep(finalizeStep)}
|
|
>
|
|
Skip to finalize
|
|
</Button>
|
|
</Group>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// finalize
|
|
return (
|
|
<Stack gap="md">
|
|
<TrainCompositionDiagram
|
|
locomotive={schedule.trainSet?.locomotive}
|
|
wagons={
|
|
schedule.trainSet?.wagons?.length
|
|
? schedule.trainSet.wagons
|
|
: displayWagonPlan
|
|
}
|
|
freightType={freightType}
|
|
trainNumber={schedule.trainNumber}
|
|
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
|
/>
|
|
<Paper
|
|
p="lg"
|
|
radius="lg"
|
|
withBorder
|
|
style={{
|
|
background: scheduleBrand.softSurface,
|
|
borderColor: scheduleBrand.mutedBorder,
|
|
}}
|
|
>
|
|
<Group gap="md" align="flex-start" wrap="nowrap">
|
|
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
|
<CheckCircle2 size={22} />
|
|
</ThemeIcon>
|
|
<Stack gap={2}>
|
|
<Text fw={600}>Ready to depart</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Finalizing locks the plan and moves the schedule to{" "}
|
|
<Text span fw={600} c="green.7">
|
|
SCHEDULED
|
|
</Text>
|
|
. Dispatch then begins rail movement and notifies the yard.
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
<Group>
|
|
{canFinalize ? (
|
|
<Button
|
|
color="green"
|
|
size="md"
|
|
radius="md"
|
|
leftSection={<CheckCircle2 size={18} />}
|
|
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="green"
|
|
size="md"
|
|
radius="md"
|
|
leftSection={<Send size={18} />}
|
|
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}
|
|
{!canFinalize && !canDispatch ? (
|
|
<Text size="sm" c="dimmed">
|
|
No actions available for this schedule status.
|
|
</Text>
|
|
) : null}
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
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>
|
|
|
|
<Paper
|
|
radius="xl"
|
|
p="xl"
|
|
style={{
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
background: scheduleBrand.heroGradient,
|
|
boxShadow: scheduleBrand.shadow,
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
top: -90,
|
|
right: -50,
|
|
width: 280,
|
|
height: 280,
|
|
borderRadius: "50%",
|
|
background: "rgba(255,255,255,0.10)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
<Stack gap="lg" style={{ position: "relative" }}>
|
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
|
<Group gap="md" align="flex-start" wrap="nowrap">
|
|
<ThemeIcon
|
|
size={56}
|
|
radius="lg"
|
|
variant="white"
|
|
style={{ color: "var(--mantine-color-green-7)" }}
|
|
>
|
|
<Train size={28} />
|
|
</ThemeIcon>
|
|
<Stack gap={6}>
|
|
<Group gap="sm" align="center" wrap="wrap">
|
|
<Title order={2} c="white" fw={700}>
|
|
{schedule.route?.name ?? "Train schedule"}
|
|
</Title>
|
|
{schedule.trainNumber ? (
|
|
<Badge
|
|
variant="white"
|
|
c="green.8"
|
|
radius="sm"
|
|
style={{ fontWeight: 600 }}
|
|
>
|
|
{schedule.trainNumber}
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
<Box maw={340}>
|
|
<RouteCorridor
|
|
onDark
|
|
origin={
|
|
schedule.originStation?.label ?? schedule.originStation?.code
|
|
}
|
|
destination={
|
|
schedule.destinationStation?.label ??
|
|
schedule.destinationStation?.code
|
|
}
|
|
/>
|
|
</Box>
|
|
<Group gap="sm" align="center">
|
|
<FreightTypeBadge freightType={schedule.freightType} />
|
|
<StatusPill status={schedule.status} />
|
|
</Group>
|
|
</Stack>
|
|
</Group>
|
|
<Group gap="sm">
|
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
|
<Button
|
|
component={Link}
|
|
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
|
variant="white"
|
|
c="green.8"
|
|
radius="lg"
|
|
size="sm"
|
|
leftSection={<Navigation size={16} />}
|
|
>
|
|
Track train
|
|
</Button>
|
|
) : null}
|
|
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
|
<Button
|
|
variant="white"
|
|
c="green.8"
|
|
radius="lg"
|
|
size="sm"
|
|
onClick={() => setMaintenanceOpen(true)}
|
|
>
|
|
Reschedule train
|
|
</Button>
|
|
) : null}
|
|
</Group>
|
|
</Group>
|
|
|
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
|
<StatTile
|
|
onDark
|
|
icon={Train}
|
|
label="Locomotive"
|
|
value={schedule.trainSet?.locomotive?.code ?? "—"}
|
|
hint={
|
|
schedule.trainSet?.locomotive?.readiness === "EXPORT_READY"
|
|
? "Export-ready"
|
|
: schedule.trainSet?.locomotive?.readiness === "IMPORT_READY"
|
|
? "Import-ready"
|
|
: undefined
|
|
}
|
|
/>
|
|
<StatTile
|
|
onDark
|
|
icon={Package}
|
|
label="Bookings"
|
|
value={schedule.bookings?.length ?? 0}
|
|
/>
|
|
<StatTile
|
|
onDark
|
|
icon={Weight}
|
|
label="Wagons / load"
|
|
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
|
|
schedule.trainSet?.totalWeightTons ?? 0
|
|
}T`}
|
|
/>
|
|
<StatTile
|
|
onDark
|
|
icon={CalendarClock}
|
|
label="Departure"
|
|
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
|
|
"en",
|
|
{ month: "short", day: "2-digit" },
|
|
)}
|
|
hint={new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
{previewResult ? (
|
|
<Badge
|
|
size="lg"
|
|
radius="sm"
|
|
variant="white"
|
|
c={previewResult.valid ? "green.8" : "red.7"}
|
|
leftSection={
|
|
<Box
|
|
w={8}
|
|
h={8}
|
|
style={{
|
|
borderRadius: 999,
|
|
background: previewResult.valid
|
|
? "var(--mantine-color-green-6)"
|
|
: "var(--mantine-color-red-6)",
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
Preview {previewResult.valid ? "valid" : "has issues"}
|
|
</Badge>
|
|
) : null}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
|
<Stack gap="lg">
|
|
{/* Workflow header with ring progress */}
|
|
<Group justify="space-between" align="center" wrap="nowrap">
|
|
<Group gap="md" align="center" wrap="nowrap">
|
|
<ThemeIcon
|
|
size={44}
|
|
radius="md"
|
|
variant="gradient"
|
|
gradient={{ from: "green", to: "teal", deg: 135 }}
|
|
>
|
|
<RouteIcon size={22} />
|
|
</ThemeIcon>
|
|
<Stack gap={2}>
|
|
<Title order={4} fw={700}>
|
|
Scheduling workflow
|
|
</Title>
|
|
<Text size="sm" c="dimmed">
|
|
{completedCount} of {stepsMeta.length} steps complete · expand any
|
|
step to edit
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
<RingProgress
|
|
size={64}
|
|
thickness={6}
|
|
roundCaps
|
|
sections={[{ value: progressPct, color: "green" }]}
|
|
label={
|
|
<Text ta="center" size="xs" fw={700} c="green.7">
|
|
{progressPct}%
|
|
</Text>
|
|
}
|
|
/>
|
|
</Group>
|
|
|
|
<WorkflowRail>
|
|
{stepsMeta.map((step, index) => (
|
|
<WorkflowStep
|
|
key={step.key}
|
|
index={index}
|
|
icon={step.icon}
|
|
title={step.title}
|
|
subtitle={step.subtitle}
|
|
state={
|
|
activeStep === index
|
|
? "active"
|
|
: step.complete
|
|
? "complete"
|
|
: "upcoming"
|
|
}
|
|
open={activeStep === index}
|
|
onToggle={() => toggleStep(index)}
|
|
rightSlot={renderStepRightSlot(step.key)}
|
|
>
|
|
{renderStepBody(step.key)}
|
|
</WorkflowStep>
|
|
))}
|
|
</WorkflowRail>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{scheduleId ? (
|
|
<RescheduleTrainDialog
|
|
scheduleId={scheduleId}
|
|
currentBookingIds={(schedule.bookings ?? []).map((b) => b.id)}
|
|
opened={maintenanceOpen}
|
|
onClose={() => setMaintenanceOpen(false)}
|
|
onComplete={() => void detailQuery.refetch()}
|
|
/>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|