Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx

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>
);
}