This commit is contained in:
Marshal
2026-07-14 11:06:49 +00:00
parent 957a185a4d
commit 6d0cf50b4d
64 changed files with 4896 additions and 404 deletions

View File

@@ -0,0 +1,152 @@
import { Freight } from "@edr/types";
import {
Button,
Checkbox,
Group,
ScrollArea,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
/**
* AVAILABLE wagons standing in the train's own yard — the only ones that can
* be coupled. Pick any number and append them to the consist.
*/
export default function AvailableWagonsPanel({
yardId,
yardLabel,
onAssign,
assigning,
}: AvailableWagonsPanelProps) {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [selected, setSelected] = useState<string[]>([]);
const wagonsQuery = useQuery(
api.wagons.list.queryOptions({
input: {
filters: { status: Freight.WagonStatus.Available, currentYardId: yardId },
},
enabled: Boolean(yardId),
}),
);
const wagons = useMemo(() => {
const q = search.trim().toLowerCase();
return (wagonsQuery.data ?? []).filter((wagon) => {
if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
return true;
});
}, [wagonsQuery.data, search, typeFilter]);
const typeOptions = useMemo(() => {
const byId = new Map<string, string>();
for (const wagon of wagonsQuery.data ?? []) {
if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name);
}
return [
{ value: "ALL", label: "All types" },
...[...byId.entries()].map(([value, label]) => ({ value, label })),
];
}, [wagonsQuery.data]);
const toggle = (wagonId: string, checked: boolean) => {
setSelected((prev) =>
checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId),
);
};
const handleAssign = () => {
if (!selected.length) return;
onAssign(selected);
setSelected([]);
};
return (
<Stack gap="sm">
<Group gap="xs" grow>
<TextInput
size="sm"
placeholder="Search wagon number…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Select
size="sm"
data={typeOptions}
value={typeFilter}
onChange={(v) => setTypeFilter(v ?? "ALL")}
/>
</Group>
<ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}>
{wagonsQuery.isLoading ? (
<Text py="md" ta="center" c="dimmed" size="sm">
Loading wagons
</Text>
) : !wagons.length ? (
<Text py="md" ta="center" c="dimmed" size="sm">
No available wagons in {yardLabel ?? "this yard"}
</Text>
) : (
wagons.map((wagon) => (
<Group
key={wagon.id}
gap="sm"
wrap="nowrap"
p="xs"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Checkbox
size="sm"
checked={selected.includes(wagon.id)}
onChange={(e) => toggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
: "Unknown type"}
</Text>
</Stack>
</Group>
))
)}
</Stack>
</ScrollArea.Autosize>
<Button
leftSection={<Plus size={16} />}
disabled={!selected.length}
loading={assigning}
onClick={handleAssign}
>
Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
</Button>
</Stack>
);
}
export interface AvailableWagonsPanelProps {
yardId: string;
yardLabel?: string | null;
onAssign: (wagonIds: string[]) => void;
assigning: boolean;
}

View File

@@ -0,0 +1,182 @@
import {
Button,
Group,
Modal,
MultiSelect,
Select,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { useEffect, useState } from "react";
import { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service";
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;
};
/**
* Step one of the Train Builder: give the train its operator code, pick the
* yard it is being assembled in, and couple at least two locomotives from that
* yard. Wagons are attached afterwards on the composition page.
*/
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast();
const [code, setCode] = useState("");
const [trainName, setTrainName] = useState("");
const [yardId, setYardId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
// Only serviceable locomotives standing in the selected yard can be coupled.
const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
enabled: Boolean(yardId),
}),
);
const build = useMutation(api.trainBuilder.build.mutationOptions());
// A locomotive belongs to one yard — switching yards invalidates the pick.
useEffect(() => {
setLocomotiveIds([]);
}, [yardId]);
useEffect(() => {
if (!opened) {
setCode("");
setTrainName("");
setYardId("");
setLocomotiveIds([]);
setNotes("");
}
}, [opened]);
const handleBuild = async () => {
if (!code.trim() || !yardId || locomotiveIds.length < 2) {
toast({
title: "Enter a train code, pick a yard, and couple at least two locomotives",
variant: "destructive",
});
return;
}
try {
const composition = await build.mutateAsync({
code: code.trim(),
currentYardId: yardId,
locomotiveIds,
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
});
toast({ title: `Train ${composition.code} built` });
onClose();
onBuilt(composition);
} catch (err) {
toast({
title: "Build failed",
description: parseError(err, "Could not build the train"),
variant: "destructive",
});
}
};
const locomotiveOptions = (locomotivesQuery.data ?? []).map((loco) => ({
value: loco.id,
label: `${loco.code}${loco.name ? `${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
}));
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>Build a train</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
A train is assembled in one yard: two or more locomotives plus wagons
standing in that same yard. Wagons are attached on the next screen.
</Text>
<Group grow>
<TextInput
label="Train code"
placeholder="e.g. 81001"
value={code}
onChange={(e) => setCode(e.currentTarget.value)}
maxLength={32}
/>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
/>
</Group>
<Select
label="Build yard"
placeholder="Select the yard the train is assembled in"
data={(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
}))}
value={yardId || null}
onChange={(v) => setYardId(v ?? "")}
searchable
/>
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back). First pick becomes the lead."
placeholder={yardId ? "Select at least two locomotives" : "Select a yard first"}
data={locomotiveOptions}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!yardId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
yardId ? "No available locomotives in this yard" : "Select a yard first"
}
/>
<Textarea
label="Notes (optional)"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button loading={build.isPending} onClick={handleBuild}>
Build train
</Button>
</Group>
</Stack>
</Modal>
);
}
export interface BuildTrainModalProps {
opened: boolean;
onClose: () => void;
onBuilt: (composition: TrainComposition) => void;
}

View File

@@ -0,0 +1,128 @@
import { Button, Group, Modal, MultiSelect, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service";
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;
};
/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
export default function ChangeLocomotivesModal({
composition,
opened,
onClose,
}: ChangeLocomotivesModalProps) {
const { toast } = useToast();
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const yardId = composition?.currentYard?.id ?? "";
const availableQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
enabled: opened && Boolean(yardId),
}),
);
const setLocomotives = useMutation(api.trainBuilder.setLocomotives.mutationOptions());
useEffect(() => {
if (opened && composition) {
setLocomotiveIds(composition.locomotives.map((l) => l.id));
}
}, [opened, composition]);
// Pickable = available locomotives in the yard + the ones already coupled
// to this train (valid to keep even though they are not "loose" anymore).
const options = useMemo(() => {
const seen = new Set<string>();
const rows: Array<{ value: string; label: string }> = [];
for (const loco of composition?.locomotives ?? []) {
seen.add(loco.id);
rows.push({
value: loco.id,
label: `${loco.code}${loco.name ? `${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T (coupled)`,
});
}
for (const loco of availableQuery.data ?? []) {
if (seen.has(loco.id)) continue;
rows.push({
value: loco.id,
label: `${loco.code}${loco.name ? `${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
});
}
return rows;
}, [composition, availableQuery.data]);
const handleSave = async () => {
if (!composition) return;
if (locomotiveIds.length < 2) {
toast({ title: "A train needs at least two locomotives", variant: "destructive" });
return;
}
try {
await setLocomotives.mutateAsync({ id: composition.id, locomotiveIds });
toast({ title: "Locomotives updated" });
onClose();
} catch (err) {
toast({
title: "Update failed",
description: parseError(err, "Could not update locomotives"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>Change locomotives</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Only available locomotives standing in{" "}
{composition?.currentYard?.label ?? "the train's yard"} can be coupled.
The first pick is the lead locomotive.
</Text>
<MultiSelect
label="Locomotives"
data={options}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage="No available locomotives in this yard"
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button loading={setLocomotives.isPending} onClick={handleSave}>
Save
</Button>
</Group>
</Stack>
</Modal>
);
}
export interface ChangeLocomotivesModalProps {
composition: TrainComposition | null;
opened: boolean;
onClose: () => void;
}

View File

@@ -0,0 +1,171 @@
import {
DragDropContext,
Draggable,
Droppable,
type DraggableProvided,
type DraggableStateSnapshot,
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2 } from "lucide-react";
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
const PortalAwareRow = ({
snapshot,
children,
}: {
snapshot: DraggableStateSnapshot;
children: ReactNode;
}) => {
if (snapshot.isDragging) {
return createPortal(children, document.body);
}
return <>{children}</>;
};
/**
* The train's ordered wagon consist. Drag to reorder (persisted on drop),
* trash to detach a wagon back to the yard.
*/
export default function ConsistWagonList({
wagons,
editable,
onReorder,
onRemove,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
if (from === to) return;
const next = [...wagons];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id));
};
if (!wagons.length) {
return (
<Text py="lg" ta="center" c="dimmed" size="sm">
No wagons in the consist yet.
</Text>
);
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
{(dropProvided) => (
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{wagons.map((wagon, index) => (
<Draggable
key={wagon.id}
draggableId={wagon.id}
index={index}
isDragDisabled={!editable || busy}
>
{(dragProvided, snapshot) => (
<WagonRow
wagon={wagon}
index={index}
dragProvided={dragProvided}
snapshot={snapshot}
editable={editable}
busy={busy}
onRemove={onRemove}
/>
)}
</Draggable>
))}
{dropProvided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
);
}
export interface ConsistWagonListProps {
wagons: TrainCompositionWagon[];
editable: boolean;
onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void;
busy?: boolean;
}
function WagonRow({
wagon,
index,
dragProvided,
snapshot,
editable,
busy,
onRemove,
}: {
wagon: TrainCompositionWagon;
index: number;
dragProvided: DraggableProvided;
snapshot: DraggableStateSnapshot;
editable: boolean;
busy: boolean;
onRemove: (wagonId: string) => void;
}) {
return (
<PortalAwareRow snapshot={snapshot}>
<Group
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
gap="sm"
wrap="nowrap"
p="sm"
style={{
...dragProvided.draggableProps.style,
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
userSelect: "none",
}}
>
{editable ? (
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
<GripVertical size={18} />
</Box>
) : null}
<Badge variant="light" color="gray" size="sm">
{index + 1}
</Badge>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
: "Unknown type"}
</Text>
</Stack>
{editable ? (
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</PortalAwareRow>
);
}

View File

@@ -0,0 +1,159 @@
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Train as TrainIcon } from "lucide-react";
import type {
TrainCompositionLocomotive,
TrainCompositionWagon,
} from "@/services/trainBuilder.service";
/**
* Visual consist: locomotives + wagons drawn in order on a rail, the way the
* train would leave the yard. Scrolls horizontally for long consists.
*/
export default function TrainConsistStrip({
locomotives,
wagons,
emptyHint = "No wagons attached yet — add wagons from the yard below.",
}: TrainConsistStripProps) {
return (
<Box
px="md"
py="lg"
style={{
overflowX: "auto",
borderRadius: 12,
background:
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, var(--mantine-color-gray-1) 100%)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Box style={{ display: "inline-block", minWidth: "100%" }}>
<Group gap={0} wrap="nowrap" align="flex-end">
{locomotives.map((loco, index) => (
<Group key={loco.id} gap={0} wrap="nowrap" align="flex-end">
{index > 0 ? <Coupler /> : null}
<LocomotiveCar locomotive={loco} />
</Group>
))}
{wagons.map((wagon) => (
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-end">
<Coupler />
<WagonCar wagon={wagon} />
</Group>
))}
</Group>
{/* The rail */}
<Box
mt={6}
style={{
height: 0,
borderTop: "3px solid var(--mantine-color-gray-4)",
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
/>
{!wagons.length ? (
<Text size="xs" c="dimmed" mt="xs">
{emptyHint}
</Text>
) : null}
</Box>
</Box>
);
}
export interface TrainConsistStripProps {
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
emptyHint?: string;
}
function Coupler() {
return (
<Box
style={{
width: 12,
height: 4,
marginBottom: 18,
background: "var(--mantine-color-gray-5)",
flexShrink: 0,
}}
/>
);
}
function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
return (
<Tooltip
label={`${locomotive.code}${locomotive.name ? `${locomotive.name}` : ""} · ${
locomotive.role === "LEAD" ? "Lead" : "Assist"
} · pulls ${locomotive.maxPullWeightTons}T`}
withArrow
>
<Stack
gap={2}
align="center"
px="sm"
py={6}
style={{
minWidth: 96,
borderRadius: "10px 14px 4px 4px",
background:
"linear-gradient(180deg, var(--mantine-color-edr-green-6) 0%, var(--mantine-color-edr-green-8) 100%)",
color: "white",
border: "1px solid var(--mantine-color-edr-green-9)",
flexShrink: 0,
cursor: "default",
}}
>
<Group gap={4} wrap="nowrap">
<TrainIcon size={13} />
<Text size="xs" fw={700} ff="monospace" lh={1.2}>
{locomotive.code}
</Text>
</Group>
<Text size="10px" fw={600} tt="uppercase" style={{ opacity: 0.85 }} lh={1}>
{locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
</Text>
</Stack>
</Tooltip>
);
}
function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
return (
<Tooltip
label={`${wagon.wagonNumber}${
wagon.wagonType
? ` · ${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
: ""
}`}
withArrow
>
<Stack
gap={2}
align="center"
px="xs"
py={6}
style={{
minWidth: 76,
borderRadius: 6,
background: "white",
border: "1px solid var(--mantine-color-gray-3)",
borderBottom: "3px solid var(--mantine-color-edr-green-3)",
flexShrink: 0,
cursor: "default",
}}
>
<Text size="10px" c="dimmed" lh={1}>
#{wagon.sequenceNumber ?? "—"}
</Text>
<Text size="xs" fw={600} ff="monospace" lh={1.2}>
{wagon.wagonNumber}
</Text>
<Text size="10px" c="dimmed" lh={1}>
{wagon.wagonType?.code ?? "—"}
</Text>
</Stack>
</Tooltip>
);
}

View File

@@ -0,0 +1,25 @@
import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
/** Badge color per built-train lifecycle status (Mantine palette keys). */
export const trainStatusColor = (status: BuiltTrainStatus | string): string => {
switch (status) {
case "AVAILABLE":
return "edr-green";
case "SCHEDULED":
return "blue";
case "IN_SERVICE":
return "teal";
case "UNDER_MAINTENANCE":
return "yellow";
case "OUT_OF_SERVICE":
return "red";
default:
return "gray";
}
};
export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
String(status)
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());