mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
552 lines
19 KiB
TypeScript
552 lines
19 KiB
TypeScript
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Grid,
|
|
Group,
|
|
Menu,
|
|
Modal,
|
|
Progress,
|
|
Stack,
|
|
Text,
|
|
} from "@mantine/core";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
AlertTriangle,
|
|
CalendarClock,
|
|
MapPin,
|
|
MoreHorizontal,
|
|
Power,
|
|
PowerOff,
|
|
Replace,
|
|
Ruler,
|
|
Trash2,
|
|
Train as TrainIcon,
|
|
TrainFront,
|
|
Weight,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
|
|
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
|
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
|
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
|
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
|
import {
|
|
directionColor,
|
|
locomotiveStatusColor,
|
|
locomotiveStatusLabel,
|
|
trainStatusColor,
|
|
trainStatusLabel,
|
|
UNFIT_LOCOMOTIVE_STATUSES,
|
|
} from "@/components/trainBuilder/trainStatus";
|
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|
import { api } from "@/services/api";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
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;
|
|
};
|
|
|
|
/** Utilization bar color: green while safe, amber when close, red when over. */
|
|
const utilizationColor = (pct: number | null) => {
|
|
if (pct == null) return "gray";
|
|
if (pct > 100) return "red";
|
|
if (pct > 85) return "yellow";
|
|
return "edr-green";
|
|
};
|
|
|
|
/**
|
|
* Train Builder workspace for one train: the visual consist, the wagon yard
|
|
* panel, and the locomotive set — everything needed to (re)compose the train.
|
|
*/
|
|
export default function TrainBuilderDetailPage() {
|
|
const { id = "" } = useParams();
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
|
const [yardModalOpen, setYardModalOpen] = useState(false);
|
|
const [disbandOpen, setDisbandOpen] = useState(false);
|
|
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
|
const { user } = useAuth();
|
|
const canUpdate = canFleetAction(user, "trains", "update");
|
|
const canDelete = canFleetAction(user, "trains", "delete");
|
|
const canAssign =
|
|
hasPermission(user, FREIGHT_PERMS.trains.assignWagons) ||
|
|
hasPermission(user, FREIGHT_PERMS.fleet.manage);
|
|
|
|
const compositionQuery = useQuery(
|
|
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
|
);
|
|
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
|
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
|
const maintenanceWagon = useMutation(
|
|
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
|
);
|
|
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
|
|
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
|
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
|
|
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
|
|
|
const composition = compositionQuery.data;
|
|
const busy =
|
|
assignWagons.isPending ||
|
|
removeWagon.isPending ||
|
|
maintenanceWagon.isPending ||
|
|
reorderWagons.isPending;
|
|
|
|
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
|
|
try {
|
|
await action();
|
|
} catch (err) {
|
|
toast({
|
|
title: failTitle,
|
|
description: parseError(err, "Something went wrong"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
if (compositionQuery.isLoading) {
|
|
return (
|
|
<PageContainer>
|
|
<Text py="xl" ta="center" c="dimmed">
|
|
Loading train…
|
|
</Text>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
if (compositionQuery.isError || !composition) {
|
|
return (
|
|
<PageContainer>
|
|
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
|
Failed to load this train.{" "}
|
|
<Button variant="subtle" size="compact-sm" onClick={() => compositionQuery.refetch()}>
|
|
Retry
|
|
</Button>
|
|
</Alert>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
const { totals } = composition;
|
|
const yard = composition.currentYard;
|
|
const blockingLocomotives = composition.locomotives.filter((loco) =>
|
|
UNFIT_LOCOMOTIVE_STATUSES.has(loco.status),
|
|
);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title={`Train ${composition.code}`}
|
|
subtitle={
|
|
composition.trainName
|
|
? `${composition.trainName} · built in ${yard?.label ?? "unknown yard"}`
|
|
: `Built in ${yard?.label ?? "unknown yard"}`
|
|
}
|
|
backTo="/dashboard/train-builder"
|
|
meta={
|
|
<Group gap="xs">
|
|
<Badge color={trainStatusColor(composition.status)} variant="light">
|
|
{trainStatusLabel(composition.status)}
|
|
</Badge>
|
|
<Badge color="blue" variant="light" ff="monospace">
|
|
IMP {composition.importTrainNumber ?? "—"}
|
|
</Badge>
|
|
<Badge color="orange" variant="light" ff="monospace">
|
|
EXP {composition.exportTrainNumber ?? "—"}
|
|
</Badge>
|
|
</Group>
|
|
}
|
|
action={
|
|
canUpdate || canDelete ? (
|
|
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
|
<Menu.Target>
|
|
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
|
Actions
|
|
</Button>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
{canUpdate ? (
|
|
<>
|
|
<Menu.Item
|
|
leftSection={<Replace size={15} />}
|
|
disabled={!composition.editable}
|
|
onClick={() => setLocoModalOpen(true)}
|
|
>
|
|
Change locomotives
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<MapPin size={15} />}
|
|
disabled={!composition.editable}
|
|
onClick={() => setYardModalOpen(true)}
|
|
>
|
|
Change yard
|
|
</Menu.Item>
|
|
{composition.status === "DEACTIVATED" ? (
|
|
<Menu.Item
|
|
leftSection={<Power size={15} />}
|
|
disabled={blockingLocomotives.length > 0}
|
|
onClick={() =>
|
|
void withToast(async () => {
|
|
await activate.mutateAsync(composition.id);
|
|
toast({ title: `Train ${composition.code} reactivated` });
|
|
}, "Could not reactivate train")
|
|
}
|
|
>
|
|
Reactivate train
|
|
</Menu.Item>
|
|
) : (
|
|
<Menu.Item
|
|
leftSection={<PowerOff size={15} />}
|
|
disabled={composition.activeSchedules.length > 0}
|
|
onClick={() => setDeactivateOpen(true)}
|
|
>
|
|
Deactivate train
|
|
</Menu.Item>
|
|
)}
|
|
</>
|
|
) : null}
|
|
{canDelete ? (
|
|
<Menu.Item
|
|
color="red"
|
|
leftSection={<Trash2 size={15} />}
|
|
disabled={composition.activeSchedules.length > 0}
|
|
onClick={() => setDisbandOpen(true)}
|
|
>
|
|
Disband train
|
|
</Menu.Item>
|
|
) : null}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
<KpiStrip
|
|
items={[
|
|
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
|
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
|
{
|
|
label: "Tare weight / haul limit",
|
|
value: `${totals.totalTareTons}T of ${totals.maxPullWeightTons}T`,
|
|
icon: Weight,
|
|
},
|
|
{
|
|
label: "Length / limit",
|
|
value: `${totals.totalLengthMeters}m / ${totals.maxTrainLengthMeters}m`,
|
|
icon: Ruler,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{!composition.editable ? (
|
|
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
|
This train is out on a dispatched run — its composition is frozen until arrival.
|
|
</Alert>
|
|
) : null}
|
|
|
|
{composition.status === "DEACTIVATED" && blockingLocomotives.length > 0 ? (
|
|
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
|
<Stack gap="xs">
|
|
<Text size="sm">
|
|
Cannot reactivate — {blockingLocomotives.length > 1 ? "these locomotives are" : "this locomotive is"}{" "}
|
|
not fit for service:{" "}
|
|
{blockingLocomotives.map((loco, i) => (
|
|
<span key={loco.id}>
|
|
{i > 0 ? ", " : ""}
|
|
<Text span fw={600} ff="monospace">
|
|
{loco.code}
|
|
</Text>{" "}
|
|
({locomotiveStatusLabel(loco.status)})
|
|
</span>
|
|
))}
|
|
.
|
|
</Text>
|
|
<Group gap="xs">
|
|
{canUpdate ? (
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="red"
|
|
leftSection={<Replace size={14} />}
|
|
disabled={!composition.editable}
|
|
onClick={() => setLocoModalOpen(true)}
|
|
>
|
|
Detach & replace locomotives
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="compact-sm"
|
|
variant="subtle"
|
|
onClick={() => navigate(`/dashboard/locomotives`)}
|
|
>
|
|
Go to locomotives
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Group gap="xs">
|
|
{composition.locomotives.map((loco) => (
|
|
<Badge
|
|
key={loco.id}
|
|
variant="light"
|
|
color={locomotiveStatusColor(loco.status)}
|
|
leftSection={<TrainFront size={12} />}
|
|
>
|
|
{loco.code} · {locomotiveStatusLabel(loco.status)}
|
|
</Badge>
|
|
))}
|
|
</Group>
|
|
|
|
<Stack gap="sm">
|
|
<TrainCompositionDiagram
|
|
locomotives={composition.locomotives.map((loco) => ({
|
|
code: loco.code,
|
|
name: loco.name,
|
|
maxPullWeightTons: loco.maxPullWeightTons,
|
|
}))}
|
|
wagons={composition.wagons.map((wagon, index) => ({
|
|
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
|
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
|
// No bookings at build time — wagons ride empty until allocation.
|
|
assignedWeightTons: 0,
|
|
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
|
wagonTypeCode: wagon.wagonType?.code ?? null,
|
|
physicalWagonNumber: wagon.wagonNumber,
|
|
allocations: [],
|
|
}))}
|
|
trainNumber={composition.code}
|
|
totalLengthMeters={totals.totalLengthMeters}
|
|
/>
|
|
<Card>
|
|
<Grid gap="lg">
|
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
|
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
The real weight check happens at allocation: booked cargo weight plus
|
|
wagon tare (gross) must stay within the locomotives' haul limit.
|
|
</Text>
|
|
</Grid.Col>
|
|
</Grid>
|
|
</Card>
|
|
</Stack>
|
|
|
|
<Grid gap="lg" align="stretch">
|
|
{composition.editable && canAssign ? (
|
|
<Grid.Col span={{ base: 12, md: 5 }}>
|
|
<Card h="100%">
|
|
<Stack gap="sm">
|
|
<Text fw={600}>Available wagons — {yard?.label ?? "yard"}</Text>
|
|
<Text size="xs" c="dimmed">
|
|
Only AVAILABLE wagons standing in the train's own yard can be coupled.
|
|
</Text>
|
|
<AvailableWagonsPanel
|
|
yardId={yard?.id ?? ""}
|
|
yardLabel={yard?.label}
|
|
exportTrainNumber={composition.exportTrainNumber}
|
|
importTrainNumber={composition.importTrainNumber}
|
|
assigning={assignWagons.isPending}
|
|
onAssign={(wagonIds) =>
|
|
void withToast(
|
|
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
|
|
"Could not add wagons",
|
|
)
|
|
}
|
|
/>
|
|
</Stack>
|
|
</Card>
|
|
</Grid.Col>
|
|
) : null}
|
|
<Grid.Col span={{ base: 12, md: composition.editable && canAssign ? 7 : 12 }}>
|
|
<Card h="100%">
|
|
<Stack gap="sm">
|
|
<Text fw={600}>Wagon order</Text>
|
|
<Text size="xs" c="dimmed">
|
|
Drag to reorder — position 1 couples right behind the locomotives.
|
|
</Text>
|
|
<ConsistWagonList
|
|
wagons={composition.wagons}
|
|
editable={composition.editable && canAssign}
|
|
busy={busy}
|
|
onReorder={(wagonIds) =>
|
|
void withToast(
|
|
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
|
|
"Could not reorder wagons",
|
|
)
|
|
}
|
|
onRemove={(wagonId) =>
|
|
void withToast(
|
|
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
|
|
"Could not detach wagon",
|
|
)
|
|
}
|
|
onMaintenance={(wagonId) =>
|
|
void withToast(
|
|
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
|
|
"Could not send wagon to maintenance",
|
|
)
|
|
}
|
|
/>
|
|
</Stack>
|
|
</Card>
|
|
</Grid.Col>
|
|
</Grid>
|
|
|
|
{composition.activeSchedules.length ? (
|
|
<Card>
|
|
<Stack gap="sm">
|
|
<Text fw={600}>Upcoming runs</Text>
|
|
{composition.activeSchedules.map((schedule) => (
|
|
<Group key={schedule.id} justify="space-between">
|
|
<Group gap="sm">
|
|
<CalendarClock size={15} color="var(--mantine-color-gray-6)" />
|
|
<Text size="sm" ff="monospace" fw={600}>
|
|
{schedule.reference ?? schedule.id.slice(0, 8)}
|
|
</Text>
|
|
{schedule.trainNumber ? (
|
|
<Text size="sm" ff="monospace" fw={700}>
|
|
{schedule.trainNumber}
|
|
</Text>
|
|
) : null}
|
|
{schedule.direction ? (
|
|
<Badge size="sm" variant="light" color={directionColor(schedule.direction)}>
|
|
{schedule.direction}
|
|
</Badge>
|
|
) : null}
|
|
<Badge size="sm" variant="light">
|
|
{schedule.status}
|
|
</Badge>
|
|
</Group>
|
|
<Button
|
|
variant="subtle"
|
|
size="compact-sm"
|
|
onClick={() =>
|
|
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
|
}
|
|
>
|
|
Open schedule
|
|
</Button>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</Card>
|
|
) : null}
|
|
|
|
<ChangeLocomotivesModal
|
|
composition={composition}
|
|
opened={locoModalOpen}
|
|
onClose={() => setLocoModalOpen(false)}
|
|
/>
|
|
|
|
<ChangeYardModal
|
|
composition={composition}
|
|
opened={yardModalOpen}
|
|
onClose={() => setYardModalOpen(false)}
|
|
/>
|
|
|
|
<Modal
|
|
opened={deactivateOpen}
|
|
onClose={() => setDeactivateOpen(false)}
|
|
title={<Text fw={600}>Deactivate train {composition.code}?</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
The train is parked and cannot be picked for new schedules until it is
|
|
reactivated. Its locomotives and wagons stay coupled.
|
|
</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setDeactivateOpen(false)}>
|
|
Keep active
|
|
</Button>
|
|
<Button
|
|
color="gray"
|
|
loading={deactivate.isPending}
|
|
onClick={() =>
|
|
void withToast(async () => {
|
|
await deactivate.mutateAsync(composition.id);
|
|
toast({ title: `Train ${composition.code} deactivated` });
|
|
setDeactivateOpen(false);
|
|
}, "Could not deactivate train")
|
|
}
|
|
>
|
|
Deactivate
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={disbandOpen}
|
|
onClose={() => setDisbandOpen(false)}
|
|
title={<Text fw={600}>Disband train {composition.code}?</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
All wagons and locomotives are released back to{" "}
|
|
{yard?.label ?? "their yard"} and the train is deleted. This cannot be undone.
|
|
</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setDisbandOpen(false)}>
|
|
Keep train
|
|
</Button>
|
|
<Button
|
|
color="red"
|
|
loading={disband.isPending}
|
|
onClick={() =>
|
|
void withToast(async () => {
|
|
await disband.mutateAsync(composition.id);
|
|
toast({ title: `Train ${composition.code} disbanded` });
|
|
navigate("/dashboard/train-builder");
|
|
}, "Could not disband train")
|
|
}
|
|
>
|
|
Disband
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
function UtilizationBar({ label, pct }: { label: string; pct: number | null }) {
|
|
return (
|
|
<Stack gap={4}>
|
|
<Group justify="space-between">
|
|
<Text size="xs" c="dimmed">
|
|
{label}
|
|
</Text>
|
|
<Text size="xs" fw={600} c={pct != null && pct > 100 ? "red" : undefined}>
|
|
{pct != null ? `${pct}%` : "—"}
|
|
</Text>
|
|
</Group>
|
|
<Progress
|
|
value={Math.min(pct ?? 0, 100)}
|
|
color={utilizationColor(pct)}
|
|
size="sm"
|
|
radius="xl"
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|