mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
train
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
Container,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Hammer,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
MapPin,
|
||||
@@ -116,6 +117,8 @@ import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityP
|
||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||
import LastMilePage from "./pages/operations/LastMilePage";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
|
||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
@@ -266,6 +269,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
|
||||
// {
|
||||
// label: "Wagon types",
|
||||
@@ -1027,6 +1036,22 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
@@ -1241,6 +1266,22 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Button,
|
||||
TextInput,
|
||||
Textarea,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Switch,
|
||||
Stack,
|
||||
@@ -79,8 +80,12 @@ const buildInitialValues = (
|
||||
): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const raw = record?.[field.name];
|
||||
if (raw !== undefined && raw !== null) {
|
||||
const raw = field.getInitialValue && record
|
||||
? field.getInitialValue(record)
|
||||
: record?.[field.name];
|
||||
if (field.type === "multiselect") {
|
||||
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
|
||||
} else if (raw !== undefined && raw !== null) {
|
||||
if (field.type === "date" && typeof raw === "string") {
|
||||
values[field.name] = raw.slice(0, 10);
|
||||
} else if (Array.isArray(raw)) {
|
||||
@@ -197,7 +202,10 @@ const RuleEngineFormDialog = ({
|
||||
|
||||
for (const field of visibleFields) {
|
||||
const raw = values[field.name];
|
||||
if (field.type === "number") {
|
||||
if (field.type === "multiselect") {
|
||||
// Always the full replacement list — the API syncs the relation to it.
|
||||
payload[field.name] = Array.isArray(raw) ? raw : [];
|
||||
} else if (field.type === "number") {
|
||||
if (raw === "" || raw === undefined) continue;
|
||||
payload[field.name] = Number(raw);
|
||||
} else if (field.type === "boolean") {
|
||||
@@ -258,6 +266,38 @@ const RuleEngineFormDialog = ({
|
||||
|
||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||
|
||||
if (field.type === "multiselect") {
|
||||
const options = field.optionsFromValues
|
||||
? field.optionsFromValues(values)
|
||||
: (field.options ?? []);
|
||||
const selected = Array.isArray(values[field.name])
|
||||
? (values[field.name] as string[])
|
||||
: [];
|
||||
return (
|
||||
<MultiSelect
|
||||
key={field.name}
|
||||
label={label}
|
||||
description={field.description}
|
||||
placeholder={
|
||||
selectOptionsLoading
|
||||
? "Loading options..."
|
||||
: (field.placeholder ?? "Select one or more")
|
||||
}
|
||||
value={selected}
|
||||
onChange={(v) => setField(field.name, v)}
|
||||
disabled={selectOptionsLoading}
|
||||
data={options
|
||||
.filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE)
|
||||
.map((opt) => ({ label: opt.label, value: opt.value }))}
|
||||
searchable
|
||||
clearable
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
@@ -0,0 +1,344 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Warehouse,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
|
||||
export interface WagonTransferRequestsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PENDING = Freight.WagonTransferRequestStatus.Pending;
|
||||
const AVAILABLE = Freight.WagonStatus.Available;
|
||||
|
||||
const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
const typeLabel = (t?: { code?: string; name?: string } | null) =>
|
||||
t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—";
|
||||
|
||||
/** Requester → destination + type + count summary line, reused in list and picker. */
|
||||
const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{yardLabel(r.fromYard)}
|
||||
</Text>
|
||||
<ArrowRight size={14} style={{ flexShrink: 0 }} />
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{yardLabel(r.toYard)}
|
||||
</Text>
|
||||
<Badge variant="light" color="grape" radius="sm">
|
||||
{r.quantity}× {typeLabel(r.wagonType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
|
||||
/**
|
||||
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
|
||||
* one to hand-pick exactly the requested number of wagons from the source yard
|
||||
* (of the requested type) and execute the move, or cancel the request.
|
||||
*/
|
||||
const WagonTransferRequestsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
}: WagonTransferRequestsModalProps) => {
|
||||
const { toast } = useToast();
|
||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: requests = [], isLoading } = useQuery({
|
||||
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
// Available wagons of the requested type sitting in the request's source yard.
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
|
||||
...api.wagons.list.queryOptions({
|
||||
input: {
|
||||
filters: active
|
||||
? {
|
||||
currentYardId: active.fromYardId,
|
||||
wagonTypeId: active.wagonTypeId,
|
||||
status: AVAILABLE,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
}),
|
||||
enabled: opened && Boolean(active),
|
||||
});
|
||||
|
||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||
|
||||
const showError = (err: unknown, fallback: string) => {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? fallback;
|
||||
toast({ title: fallback, description: String(message), variant: "destructive" });
|
||||
};
|
||||
|
||||
const openPicker = (r: WagonTransferRequest) => {
|
||||
setActive(r);
|
||||
setPicked(new Set());
|
||||
};
|
||||
const closePicker = () => {
|
||||
setActive(null);
|
||||
setPicked(new Set());
|
||||
};
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (active && next.size >= active.quantity) return prev; // cap at quantity
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const need = active?.quantity ?? 0;
|
||||
const shortfall = active ? Math.max(0, need - wagons.length) : 0;
|
||||
|
||||
const handleFulfill = async () => {
|
||||
if (!active || picked.size !== need) return;
|
||||
try {
|
||||
await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] });
|
||||
toast({
|
||||
title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)} → ${yardLabel(
|
||||
active.toYard,
|
||||
)}`,
|
||||
});
|
||||
closePicker();
|
||||
} catch (err) {
|
||||
showError(err, "Transfer failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (r: WagonTransferRequest) => {
|
||||
try {
|
||||
await cancel.mutateAsync({ id: r.id });
|
||||
toast({ title: "Request cancelled" });
|
||||
} catch (err) {
|
||||
showError(err, "Cancel failed");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedWagons = useMemo(
|
||||
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
|
||||
[wagons],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="min(760px, 96vw)"
|
||||
radius="lg"
|
||||
centered
|
||||
overlayProps={{ blur: 2 }}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||||
<Inbox size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Wagon Transfer Requests</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{active
|
||||
? "Pick the wagons to move, then transfer"
|
||||
: "OCC queue — pick wagons and complete each move"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{!active ? (
|
||||
// ---- Pending queue ----
|
||||
isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : requests.length === 0 ? (
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Stack align="center" gap={6}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>No pending transfer requests</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||
When staff request a yard-to-yard wagon move, it appears here for
|
||||
you to fulfil.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<RequestSummary r={r} />
|
||||
{r.note ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
“{r.note}”
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={14} />}
|
||||
loading={cancel.isPending}
|
||||
onClick={() => handleCancel(r)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
onClick={() => openPicker(r)}
|
||||
>
|
||||
Fulfil
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
) : (
|
||||
// ---- Wagon picker for the active request ----
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="var(--mantine-color-gray-0)">
|
||||
<RequestSummary r={active} />
|
||||
</Card>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
Select wagons in {yardLabel(active.fromYard)}
|
||||
</Text>
|
||||
<Badge
|
||||
color={picked.size === need ? "teal" : "gray"}
|
||||
variant={picked.size === need ? "filled" : "light"}
|
||||
>
|
||||
{picked.size} / {need} selected
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{wagonsLoading ? (
|
||||
<Group justify="center" p="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : sortedWagons.length === 0 ? (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group gap={8} justify="center">
|
||||
<Warehouse size={16} />
|
||||
<Text size="sm" c="dimmed">
|
||||
No available wagons of this type in {yardLabel(active.fromYard)}.
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{shortfall > 0 ? (
|
||||
<Text size="xs" c="orange.7">
|
||||
Only {sortedWagons.length} available — {shortfall} short of the{" "}
|
||||
{need} requested.
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
<Stack gap={6}>
|
||||
{sortedWagons.map((w) => {
|
||||
const checked = picked.has(w.id);
|
||||
const atCap = !checked && picked.size >= need;
|
||||
return (
|
||||
<Card
|
||||
key={w.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="xs"
|
||||
onClick={() => !atCap && toggle(w.id)}
|
||||
style={{
|
||||
cursor: atCap ? "not-allowed" : "pointer",
|
||||
borderColor: checked
|
||||
? "var(--mantine-color-edr-green-4)"
|
||||
: undefined,
|
||||
opacity: atCap ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{/* Visual only — the Card's onClick owns the toggle so a
|
||||
click on the box doesn't fire both and cancel out. */}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
readOnly
|
||||
disabled={atCap}
|
||||
color="edr-green"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={closePicker}
|
||||
>
|
||||
Back to queue
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
loading={fulfill.isPending}
|
||||
disabled={picked.size !== need}
|
||||
onClick={handleFulfill}
|
||||
>
|
||||
Transfer {need} wagon{need === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WagonTransferRequestsModal;
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Select,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
@@ -126,11 +125,12 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
|
||||
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
||||
const [transferQty, setTransferQty] = useState(0);
|
||||
const [freeAfterMove, setFreeAfterMove] = useState(false);
|
||||
const [toAssignedQty, setToAssignedQty] = useState(0);
|
||||
const [toAvailableQty, setToAvailableQty] = useState(0);
|
||||
|
||||
const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions());
|
||||
const createRequest = useMutation(
|
||||
api.wagonTransferRequests.create.mutationOptions(),
|
||||
);
|
||||
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
|
||||
|
||||
const yardName = useMemo(() => {
|
||||
@@ -187,13 +187,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
() => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED),
|
||||
[matching],
|
||||
);
|
||||
// Available first, then assigned, then the rest — a partial move relocates
|
||||
// idle wagons before touching assigned ones.
|
||||
const transferPool = useMemo(
|
||||
() => [...availableWagons, ...assignedWagons, ...otherWagons],
|
||||
[availableWagons, assignedWagons, otherWagons],
|
||||
);
|
||||
|
||||
const total = matching.length;
|
||||
const availableCount = availableWagons.length;
|
||||
const assignedCount = assignedWagons.length;
|
||||
@@ -214,7 +207,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
useEffect(() => {
|
||||
setTransferYardId(null);
|
||||
setTransferQty(0);
|
||||
setFreeAfterMove(false);
|
||||
setToAssignedQty(0);
|
||||
setToAvailableQty(0);
|
||||
}, [yardId, typeId]);
|
||||
@@ -238,25 +230,27 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
toast({ title: fallback, description: String(message), variant: "destructive" });
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!transferYardId || transferQty < 1) return;
|
||||
const ids = transferPool.slice(0, transferQty).map((w) => w.id);
|
||||
if (!ids.length) return;
|
||||
// Request-only: the requester specifies count + destination; OCC later picks
|
||||
// the physical wagons and executes the move. No wagons are moved here.
|
||||
const handleRequest = async () => {
|
||||
if (!yardId || !typeId || !transferYardId || transferQty < 1) return;
|
||||
try {
|
||||
const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId });
|
||||
if (freeAfterMove) {
|
||||
await setStatus.mutateAsync({ wagonIds: ids, status: AVAILABLE });
|
||||
}
|
||||
await createRequest.mutateAsync({
|
||||
fromYardId: yardId,
|
||||
toYardId: transferYardId,
|
||||
wagonTypeId: typeId,
|
||||
quantity: transferQty,
|
||||
});
|
||||
toast({
|
||||
title: `Moved ${res.moved} wagon(s) to ${yardName(transferYardId)}${
|
||||
freeAfterMove ? " · set Available" : ""
|
||||
}`,
|
||||
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
|
||||
yardId,
|
||||
)} → ${yardName(transferYardId)}`,
|
||||
description: "OCC will pick the wagons and complete the move.",
|
||||
});
|
||||
setTransferQty(0);
|
||||
setTransferYardId(null);
|
||||
setFreeAfterMove(false);
|
||||
} catch (err) {
|
||||
showError(err, "Transfer failed");
|
||||
showError(err, "Request failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -279,7 +273,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
}
|
||||
};
|
||||
|
||||
const busy = transfer.isPending || setStatus.isPending;
|
||||
const busy = createRequest.isPending || setStatus.isPending;
|
||||
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
|
||||
|
||||
return (
|
||||
@@ -402,12 +396,15 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
{/* Transfer */}
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Card withBorder radius="md" h="100%" padding="lg">
|
||||
<Group gap="xs" mb="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="grape" radius="md" size="md">
|
||||
<ArrowRightLeft size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Move to another yard</Text>
|
||||
<Text fw={700}>Request transfer to another yard</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Sends a request to OCC — they pick the wagons and complete the move.
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
@@ -424,12 +421,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
<Switch
|
||||
checked={freeAfterMove}
|
||||
onChange={(e) => setFreeAfterMove(e.currentTarget.checked)}
|
||||
label="Set moved wagons to Available"
|
||||
color="teal"
|
||||
/>
|
||||
{transferYardId && transferQty > 0 ? (
|
||||
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
@@ -453,12 +444,13 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
) : null}
|
||||
<Button
|
||||
leftSection={<ArrowRightLeft size={16} />}
|
||||
onClick={handleTransfer}
|
||||
loading={transfer.isPending}
|
||||
onClick={handleRequest}
|
||||
loading={createRequest.isPending}
|
||||
disabled={busy || !transferYardId || transferQty < 1}
|
||||
color="edr-green"
|
||||
>
|
||||
Move {transferQty > 0 ? `${transferQty} ` : ""}wagon{transferQty === 1 ? "" : "s"}
|
||||
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
|
||||
{transferQty === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -99,6 +99,8 @@ export const QUERY_KEYS = {
|
||||
] as const,
|
||||
locomotives: (routeId?: string) =>
|
||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||
availableTrains: (routeId?: string) =>
|
||||
["train-scheduling", "available-trains", routeId ?? "all"] as const,
|
||||
stations: () => ["train-scheduling", "stations"] as const,
|
||||
schedules: (filters?: unknown) =>
|
||||
["train-scheduling", "schedules", filters ?? {}] as const,
|
||||
@@ -122,6 +124,12 @@ export const QUERY_KEYS = {
|
||||
["fleet", "list", resource] as const,
|
||||
},
|
||||
|
||||
TRAIN_BUILDER: {
|
||||
ROOT: ["train-builder"] as const,
|
||||
list: (filters?: unknown) => ["train-builder", "list", filters ?? {}] as const,
|
||||
composition: (id: string) => ["train-builder", "composition", id] as const,
|
||||
},
|
||||
|
||||
VEHICLES: {
|
||||
ROOT: ["vehicles"] as const,
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Plus, Warehouse } from "lucide-react";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -15,6 +15,7 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -48,6 +49,7 @@ const FleetResourcePage = () => {
|
||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
||||
const [transferRequestsOpen, setTransferRequestsOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
@@ -373,15 +375,26 @@ const FleetResourcePage = () => {
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
{slug === "wagons" ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Inbox size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setTransferRequestsOpen(true)}
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
@@ -605,6 +618,13 @@ const FleetResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonTransferRequestsModal
|
||||
opened={transferRequestsOpen}
|
||||
onClose={() => setTransferRequestsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
|
||||
@@ -54,8 +54,8 @@ interface CargoNode extends RuleEngineRecord {
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||
unitOfMeasure?: string | null;
|
||||
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
|
||||
wagonTypeId?: string | null;
|
||||
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
|
||||
wagonTypes?: { id: string; code?: string; name?: string }[];
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
@@ -82,16 +82,19 @@ const FORM_FIELDS: FormFieldDef[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// Wagon type that carries this (bulk) commodity — drives train-scheduling
|
||||
// wagon resolution. Optional: leave "None" for grouping categories and
|
||||
// container/legacy cargo; set it on scheduled bulk commodities.
|
||||
// Wagon types that can carry this (bulk) commodity — drive train-scheduling
|
||||
// wagon resolution (the plan uses whichever type the train/yard has).
|
||||
// Optional: leave empty for grouping categories and container/legacy cargo;
|
||||
// set them on scheduled bulk commodities.
|
||||
// Options injected at render from useWagonTypeOptions.
|
||||
name: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
type: "select",
|
||||
name: "wagonTypeIds",
|
||||
label: "Wagon types",
|
||||
type: "multiselect",
|
||||
optional: true,
|
||||
placeholder: "Select wagon type (bulk cargo)",
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
|
||||
placeholder: "Select wagon types (bulk cargo)",
|
||||
options: [],
|
||||
getInitialValue: (record) =>
|
||||
((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id),
|
||||
},
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
@@ -119,19 +122,13 @@ const CargoTypesPage = () => {
|
||||
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
|
||||
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeId"
|
||||
? {
|
||||
...field,
|
||||
options: [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
...(wagonTypeOptions ?? []),
|
||||
],
|
||||
}
|
||||
field.name === "wagonTypeIds"
|
||||
? { ...field, options: wagonTypeOptions ?? [] }
|
||||
: field,
|
||||
),
|
||||
[wagonTypeOptions],
|
||||
|
||||
@@ -153,7 +153,9 @@ const RuleEngineResourcePage = () => {
|
||||
config?.formFields.some((f) => f.name === "rateId"),
|
||||
);
|
||||
const usesWagonTypeField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "wagonTypeId"),
|
||||
config?.formFields.some(
|
||||
(f) => f.name === "wagonTypeId" || f.name === "wagonTypeIds",
|
||||
),
|
||||
);
|
||||
|
||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||
@@ -206,6 +208,13 @@ const RuleEngineResourcePage = () => {
|
||||
options: wagonTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "wagonTypeIds") {
|
||||
return {
|
||||
...field,
|
||||
type: "multiselect" as const,
|
||||
options: wagonTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
|
||||
|
||||
@@ -55,6 +55,12 @@ export interface FormFieldDef {
|
||||
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
|
||||
*/
|
||||
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
|
||||
/**
|
||||
* Derive the field's initial form value from the record being edited when it
|
||||
* doesn't live under `record[name]` — e.g. a multiselect of ids backed by a
|
||||
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
||||
*/
|
||||
getInitialValue?: (record: Record<string, unknown>) => unknown;
|
||||
}
|
||||
|
||||
export interface RuleEngineOrderConfig {
|
||||
@@ -258,11 +264,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
|
||||
{
|
||||
name: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
type: "select",
|
||||
name: "wagonTypeIds",
|
||||
label: "Wagon types",
|
||||
type: "multiselect",
|
||||
required: true,
|
||||
description: "Wagon type used to carry this container during train scheduling.",
|
||||
description:
|
||||
"Wagon types that can carry this container during train scheduling (one container size per wagon at a time).",
|
||||
getInitialValue: (record) =>
|
||||
((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id),
|
||||
},
|
||||
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
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,
|
||||
MoreHorizontal,
|
||||
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 ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
|
||||
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
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 [disbandOpen, setDisbandOpen] = useState(false);
|
||||
|
||||
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 reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
|
||||
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
||||
|
||||
const composition = compositionQuery.data;
|
||||
const busy =
|
||||
assignWagons.isPending || removeWagon.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;
|
||||
|
||||
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={
|
||||
<Badge color={trainStatusColor(composition.status)} variant="light">
|
||||
{trainStatusLabel(composition.status)}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
||||
<Menu.Target>
|
||||
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
||||
Actions
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Replace size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDisbandOpen(true)}
|
||||
>
|
||||
Disband train
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
||||
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
||||
{
|
||||
label: "Max gross / haul limit",
|
||||
value: `${totals.maxGrossTons}T / ${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}
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600}>Consist</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{composition.locomotives.length} locomotive
|
||||
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
|
||||
{totals.wagonCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<TrainConsistStrip
|
||||
locomotives={composition.locomotives}
|
||||
wagons={composition.wagons}
|
||||
/>
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<UtilizationBar
|
||||
label="Weight utilization (fully loaded)"
|
||||
pct={totals.weightUtilizationPct}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Grid gap="lg" align="stretch">
|
||||
{composition.editable ? (
|
||||
<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}
|
||||
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 ? 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}
|
||||
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",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
<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)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Hammer,
|
||||
Ruler,
|
||||
Search,
|
||||
Train as TrainIcon,
|
||||
TrainFront,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
||||
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
BuiltTrainListFilters,
|
||||
BuiltTrainStatus,
|
||||
BuiltTrainSummary,
|
||||
} from "@/services/trainBuilder.service";
|
||||
|
||||
/**
|
||||
* Train Builder board: every built train (code, yard, locomotive set, consist
|
||||
* totals, lifecycle status) plus the entry point for building a new one.
|
||||
*/
|
||||
export default function TrainBuilderListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
|
||||
const [yardFilter, setYardFilter] = useState("ALL");
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination((prev) =>
|
||||
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
|
||||
);
|
||||
}, [setPagination]);
|
||||
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [debouncedSearch, resetPage]);
|
||||
|
||||
const filters = useMemo<BuiltTrainListFilters>(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
|
||||
...(yardFilter !== "ALL" ? { currentYardId: yardFilter } : {}),
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
debouncedSearch,
|
||||
statusFilter,
|
||||
yardFilter,
|
||||
],
|
||||
);
|
||||
|
||||
const trainsQuery = useQuery(
|
||||
api.trainBuilder.list.queryOptions({
|
||||
input: { filters },
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
|
||||
const trains = trainsQuery.data?.items ?? [];
|
||||
const totalTrains = trainsQuery.data?.meta.total ?? 0;
|
||||
const pageCount = Math.max(1, trainsQuery.data?.meta.totalPages ?? 1);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const base = { available: 0, scheduled: 0, inService: 0, wagons: 0 };
|
||||
for (const train of trains) {
|
||||
if (train.status === "AVAILABLE") base.available += 1;
|
||||
if (train.status === "SCHEDULED") base.scheduled += 1;
|
||||
if (train.status === "IN_SERVICE") base.inService += 1;
|
||||
base.wagons += train.wagonCount;
|
||||
}
|
||||
return base;
|
||||
}, [trains]);
|
||||
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
})),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
|
||||
const columns = useMemo((): ColumnDef<BuiltTrainSummary>[] => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
{
|
||||
id: "code",
|
||||
header: "Train",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<TrainIcon size={16} />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
|
||||
{row.original.code}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{row.original.trainName ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "yard",
|
||||
header: "Yard",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.currentYard?.label ?? "—"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "locomotives",
|
||||
header: "Locomotives",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const locos = row.original.locomotives;
|
||||
if (!locos.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<TrainFront size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={500}>
|
||||
{locos.map((l) => l.code).join(" + ")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "consist",
|
||||
header: "Consist",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
|
||||
{row.original.totalLengthMeters}m
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "capacity",
|
||||
header: "Haul limit",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{row.original.maxPullWeightTons}T
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Badge color={trainStatusColor(row.original.status)} variant="light">
|
||||
{trainStatusLabel(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableStatus = trainsQuery.isLoading
|
||||
? "loading"
|
||||
: trainsQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Train Builder"
|
||||
subtitle="Assemble coded trains from locomotives and wagons in a yard, ready to schedule as a unit."
|
||||
action={
|
||||
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
|
||||
Build train
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{ label: "Trains", value: totalTrains, icon: TrainIcon },
|
||||
{ label: "Available", value: stats.available, icon: Hammer },
|
||||
{ label: "Scheduled / in service", value: stats.scheduled + stats.inService, icon: Weight },
|
||||
{ label: "Wagons coupled", value: stats.wagons, icon: Ruler },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Search by code or name…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setStatusFilter(v as "ALL" | BuiltTrainStatus);
|
||||
resetPage();
|
||||
}}
|
||||
data={[
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
{ value: "AVAILABLE", label: "Available" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
{ value: "IN_SERVICE", label: "In service" },
|
||||
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
|
||||
{ value: "OUT_OF_SERVICE", label: "Out of service" },
|
||||
]}
|
||||
w={180}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Yard"
|
||||
searchable
|
||||
value={yardFilter}
|
||||
onChange={(v) => {
|
||||
setYardFilter(v ?? "ALL");
|
||||
resetPage();
|
||||
}}
|
||||
data={[{ value: "ALL", label: "All yards" }, ...yardOptions]}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={trains}
|
||||
status={tableStatus}
|
||||
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
|
||||
error={
|
||||
trainsQuery.isError
|
||||
? {
|
||||
message: "Failed to load trains.",
|
||||
onRetry: () => void trainsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage="No trains built yet — build the first one"
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: totalTrains,
|
||||
}}
|
||||
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: "trains" } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<BuildTrainModal
|
||||
opened={buildOpen}
|
||||
onClose={() => setBuildOpen(false)}
|
||||
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -41,10 +40,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||
import {
|
||||
locomotiveOption,
|
||||
showScheduleWarnings,
|
||||
} from "@/components/trainScheduling/locomotiveOptions";
|
||||
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
@@ -119,7 +115,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
||||
const [trainId, setTrainId] = useState("");
|
||||
// Recomputed each time the create modal opens so a long-lived tab can't keep
|
||||
// offering a stale "now" as the earliest selectable departure.
|
||||
const minScheduleDate = useMemo(
|
||||
@@ -186,9 +182,10 @@ export default function TrainScheduleV2ListPage() {
|
||||
const routesQuery = useQuery(
|
||||
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
|
||||
);
|
||||
const locomotivesQuery = useQuery(
|
||||
api.trainScheduling.availableLocomotives.queryOptions({
|
||||
input: { routeId: routeId || undefined },
|
||||
const trainsQuery = useQuery(
|
||||
api.trainScheduling.availableTrains.queryOptions({
|
||||
input: { routeId },
|
||||
enabled: Boolean(routeId),
|
||||
}),
|
||||
);
|
||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||
@@ -203,17 +200,17 @@ export default function TrainScheduleV2ListPage() {
|
||||
|
||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||
|
||||
const locomotiveYardHint = useMemo(() => {
|
||||
const trainYardHint = useMemo(() => {
|
||||
if (!selectedRoute) return "Select a route first";
|
||||
const originLabel =
|
||||
selectedRoute.originYard?.label ??
|
||||
selectedRoute.originYard?.code ??
|
||||
"the route origin yard";
|
||||
return `All in-service locomotives are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
|
||||
return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
|
||||
}, [selectedRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocomotiveIds([]);
|
||||
setTrainId("");
|
||||
}, [routeId]);
|
||||
|
||||
// Filtering, sorting, and paging all happen server-side — `schedules` IS the
|
||||
@@ -326,10 +323,29 @@ export default function TrainScheduleV2ListPage() {
|
||||
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
|
||||
},
|
||||
{
|
||||
id: "loco",
|
||||
header: "Locomotives",
|
||||
id: "train",
|
||||
header: "Train",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
// Schedules created from the Train Builder carry the train code;
|
||||
// legacy rows fall back to their locomotive set.
|
||||
if (row.original.train) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Train size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
|
||||
{row.original.train.code}
|
||||
</Text>
|
||||
{row.original.train.trainName ? (
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{row.original.train.trainName}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
const locos =
|
||||
row.original.locomotives && row.original.locomotives.length > 0
|
||||
? row.original.locomotives
|
||||
@@ -456,9 +472,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
}, [navigate, cancel.isPending, cancel, toast]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
|
||||
if (!routeId || !scheduleDate || !trainId) {
|
||||
toast({
|
||||
title: "Select route, date, and at least two locomotives",
|
||||
title: "Select route, date, and the train to run",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -475,7 +491,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
payload: {
|
||||
routeId,
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
locomotiveIds,
|
||||
trainId,
|
||||
},
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
@@ -728,7 +744,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
/>
|
||||
{routeId ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{locomotiveYardHint}
|
||||
{trainYardHint}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
@@ -738,24 +754,26 @@ export default function TrainScheduleV2ListPage() {
|
||||
value={scheduleDate}
|
||||
onChange={(e) => setScheduleDate(e.currentTarget.value)}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Locomotives"
|
||||
description="A train must be pulled by at least two locomotives (front and back)"
|
||||
placeholder={
|
||||
routeId ? "Select at least two locomotives" : "Select a route first"
|
||||
}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => locomotiveOption(l))}
|
||||
value={locomotiveIds}
|
||||
onChange={setLocomotiveIds}
|
||||
<Select
|
||||
label="Train"
|
||||
description="A built train (Train Builder) runs this departure with its locomotives and wagons"
|
||||
placeholder={routeId ? "Select a train" : "Select a route first"}
|
||||
data={(trainsQuery.data ?? []).map((train) => ({
|
||||
value: train.id,
|
||||
label: `${train.code}${train.trainName ? ` — ${train.trainName}` : ""} · ${
|
||||
train.locomotives.length
|
||||
} locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${
|
||||
train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""
|
||||
}`,
|
||||
}))}
|
||||
value={trainId || null}
|
||||
onChange={(v) => setTrainId(v ?? "")}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
error={
|
||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
||||
? "Select at least two locomotives"
|
||||
: undefined
|
||||
}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
routeId
|
||||
? "No built trains yet — assemble one in the Train Builder first"
|
||||
: "Select a route first"
|
||||
}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
|
||||
@@ -156,6 +156,7 @@ import {
|
||||
import {
|
||||
locomotivesService,
|
||||
type Locomotive,
|
||||
type LocomotiveListFilters,
|
||||
type SaveLocomotivePayload,
|
||||
} from "./locomotives.service";
|
||||
import { overviewService } from "./overview.service";
|
||||
@@ -181,13 +182,24 @@ import {
|
||||
type SaveSignaturePayload,
|
||||
} from "./signatures.service";
|
||||
import { trainService, type Train } from "./trains.service";
|
||||
import {
|
||||
trainBuilderService,
|
||||
type AvailableTrain,
|
||||
type BuildTrainPayload,
|
||||
type BuiltTrainListFilters,
|
||||
type BuiltTrainListResponse,
|
||||
type TrainComposition,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||
import {
|
||||
wagonService,
|
||||
wagonTransferRequestService,
|
||||
type Wagon,
|
||||
type WagonListFilters,
|
||||
type WagonMovementRecord,
|
||||
type WagonTransferRequest,
|
||||
type CreateTransferRequestPayload,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -213,6 +225,19 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.BOOKINGS.ROOT,
|
||||
];
|
||||
|
||||
/**
|
||||
* Train Builder mutations change wagon/locomotive availability and the
|
||||
* schedule-creation train picker alongside the builder's own lists.
|
||||
*/
|
||||
const TRAIN_BUILDER_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.TRAIN_BUILDER.ROOT,
|
||||
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
["wagons"],
|
||||
["locomotives"],
|
||||
["trains"],
|
||||
QUERY_KEYS.FLEET.ROOT,
|
||||
];
|
||||
|
||||
export const api = {
|
||||
trainScheduling: {
|
||||
// ── Queries ────────────────────────────────────────────────────────────
|
||||
@@ -286,6 +311,14 @@ export const api = {
|
||||
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId),
|
||||
),
|
||||
|
||||
availableTrains: endpoint<{ routeId: string }, AvailableTrain[]>(
|
||||
"train-scheduling",
|
||||
"available-trains",
|
||||
({ routeId }) =>
|
||||
trainBuilderService.availableTrains(routeId).then((r) => r.data),
|
||||
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
|
||||
),
|
||||
|
||||
bookableSchedules: endpoint<
|
||||
{ originYardId?: string | null; destinationYardId?: string | null },
|
||||
BookableSchedule[]
|
||||
@@ -1621,6 +1654,55 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
wagonTransferRequests: {
|
||||
list: endpoint<
|
||||
{ status?: WagonTransferRequest["status"] },
|
||||
WagonTransferRequest[]
|
||||
>(
|
||||
"wagonTransferRequests",
|
||||
"list",
|
||||
({ status }) =>
|
||||
wagonTransferRequestService.list(status).then((r) => r.data),
|
||||
({ status }) => ["wagonTransferRequests", "list", status ?? "ALL"],
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, WagonTransferRequest>(
|
||||
"wagonTransferRequests",
|
||||
"getById",
|
||||
({ id }) => wagonTransferRequestService.getById(id).then((r) => r.data),
|
||||
({ id }) => ["wagonTransferRequests", "detail", id],
|
||||
),
|
||||
|
||||
create: endpoint<CreateTransferRequestPayload, WagonTransferRequest>(
|
||||
"wagonTransferRequests",
|
||||
"create",
|
||||
(payload) =>
|
||||
wagonTransferRequestService.create(payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
|
||||
fulfill: endpoint<
|
||||
{ id: string; wagonIds: string[] },
|
||||
WagonTransferRequest
|
||||
>(
|
||||
"wagonTransferRequests",
|
||||
"fulfill",
|
||||
({ id, wagonIds }) =>
|
||||
wagonTransferRequestService.fulfill(id, wagonIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"], ["wagons"]],
|
||||
),
|
||||
|
||||
cancel: endpoint<{ id: string }, WagonTransferRequest>(
|
||||
"wagonTransferRequests",
|
||||
"cancel",
|
||||
({ id }) => wagonTransferRequestService.cancel(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
},
|
||||
|
||||
trains: {
|
||||
list: endpoint<void, Train[]>(
|
||||
"trains",
|
||||
@@ -1661,6 +1743,80 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
// Train Builder — persistent coded consists (2+ locomotives + ordered wagons)
|
||||
// that train scheduling can reference as a unit. Every mutation also touches
|
||||
// wagon/locomotive availability, so those roots are invalidated together.
|
||||
trainBuilder: {
|
||||
list: endpoint<{ filters?: BuiltTrainListFilters }, BuiltTrainListResponse>(
|
||||
"train-builder",
|
||||
"list",
|
||||
({ filters }) => trainBuilderService.list(filters).then((r) => r.data),
|
||||
({ filters }) => QUERY_KEYS.TRAIN_BUILDER.list(filters),
|
||||
),
|
||||
|
||||
composition: endpoint<{ id: string }, TrainComposition>(
|
||||
"train-builder",
|
||||
"composition",
|
||||
({ id }) => trainBuilderService.getComposition(id).then((r) => r.data),
|
||||
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
|
||||
),
|
||||
|
||||
build: endpoint<BuildTrainPayload, TrainComposition>(
|
||||
"train-builder",
|
||||
"build",
|
||||
(payload) => trainBuilderService.build(payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
setLocomotives: endpoint<
|
||||
{ id: string; locomotiveIds: string[] },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"setLocomotives",
|
||||
({ id, locomotiveIds }) =>
|
||||
trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
"train-builder",
|
||||
"assignWagons",
|
||||
({ id, wagonIds }) =>
|
||||
trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||
"train-builder",
|
||||
"removeWagon",
|
||||
({ id, wagonId }) =>
|
||||
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
"train-builder",
|
||||
"reorderWagons",
|
||||
({ id, wagonIds }) =>
|
||||
trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
disband: endpoint<string, void>(
|
||||
"train-builder",
|
||||
"disband",
|
||||
(id) => trainBuilderService.disband(id).then(() => undefined),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
},
|
||||
|
||||
locomotives: {
|
||||
list: endpoint<void, Locomotive[]>(
|
||||
"locomotives",
|
||||
@@ -1669,6 +1825,13 @@ export const api = {
|
||||
() => ["locomotives"],
|
||||
),
|
||||
|
||||
listFiltered: endpoint<{ filters?: LocomotiveListFilters }, Locomotive[]>(
|
||||
"locomotives",
|
||||
"listFiltered",
|
||||
({ filters }) => locomotivesService.getAll(filters ?? {}).then((r) => r.data),
|
||||
({ filters }) => ["locomotives", "list", filters ?? {}],
|
||||
),
|
||||
|
||||
create: endpoint<Partial<SaveLocomotivePayload>, Locomotive>(
|
||||
"locomotives",
|
||||
"create",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { api as apiClient } from "../auth/http";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirror the freight API's train-builder responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BuiltTrainStatus =
|
||||
| "AVAILABLE"
|
||||
| "SCHEDULED"
|
||||
| "IN_SERVICE"
|
||||
| "UNDER_MAINTENANCE"
|
||||
| "OUT_OF_SERVICE";
|
||||
|
||||
export interface YardRefLite {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface BuiltTrainSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
status: BuiltTrainStatus;
|
||||
createdAt: string;
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
wagonCount: number;
|
||||
maxGrossTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
}
|
||||
|
||||
export interface TrainCompositionLocomotive {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
locomotiveType: "DIESEL" | "ELECTRIC";
|
||||
status: string;
|
||||
sequenceNo: number;
|
||||
role: "LEAD" | "ASSIST";
|
||||
currentYardId: string | null;
|
||||
currentYard: YardRefLite | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
}
|
||||
|
||||
export interface TrainCompositionWagon {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
sequenceNumber: number | null;
|
||||
status: string;
|
||||
wagonType: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
capacityTons: number;
|
||||
tareWeightTons: number;
|
||||
lengthMeters: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TrainCompositionTotals {
|
||||
wagonCount: number;
|
||||
totalTareTons: number;
|
||||
totalCapacityTons: number;
|
||||
maxGrossTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
weightUtilizationPct: number | null;
|
||||
lengthUtilizationPct: number | null;
|
||||
}
|
||||
|
||||
export interface TrainComposition {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
status: BuiltTrainStatus;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: TrainCompositionLocomotive[];
|
||||
wagons: TrainCompositionWagon[];
|
||||
totals: TrainCompositionTotals;
|
||||
activeSchedules: Array<{ id: string; status: string; reference: string | null }>;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export interface BuiltTrainListFilters {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
status?: BuiltTrainStatus;
|
||||
currentYardId?: string;
|
||||
sortBy?: "code" | "trainName" | "status" | "createdAt";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export interface BuiltTrainListResponse {
|
||||
items: BuiltTrainSummary[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildTrainPayload {
|
||||
code: string;
|
||||
currentYardId: string;
|
||||
locomotiveIds: string[];
|
||||
wagonIds?: string[];
|
||||
trainName?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Built train annotated for the schedule-creation picker. */
|
||||
export interface AvailableTrain {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
status: BuiltTrainStatus;
|
||||
currentYardId: string | null;
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
wagonCount: number;
|
||||
maxGrossTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
atOriginYard: boolean;
|
||||
futureScheduleCount: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BASE = "/train-builder";
|
||||
|
||||
const toQuery = (filters: BuiltTrainListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
};
|
||||
|
||||
export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
|
||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
|
||||
/** Built trains schedulable on a route (train-scheduling picker). */
|
||||
availableTrains: (routeId: string) =>
|
||||
apiClient.get<AvailableTrain[]>(`/train-scheduling/available-trains`, {
|
||||
params: { routeId },
|
||||
}),
|
||||
};
|
||||
@@ -90,3 +90,53 @@ export const wagonService = {
|
||||
bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus) =>
|
||||
apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status }),
|
||||
};
|
||||
|
||||
/**
|
||||
* A two-person wagon-transfer request: a requester asks for N wagons of a type
|
||||
* to move between yards (count only); OCC hand-picks the wagons and fulfils it.
|
||||
*/
|
||||
export interface WagonTransferRequest {
|
||||
id: string;
|
||||
fromYardId: string;
|
||||
toYardId: string;
|
||||
wagonTypeId: string;
|
||||
quantity: number;
|
||||
status: Freight.WagonTransferRequestStatus;
|
||||
requestedByUserId: string | null;
|
||||
fulfilledByUserId: string | null;
|
||||
fulfilledAt: string | null;
|
||||
note: string | null;
|
||||
fromYard?: { id: string; label?: string; code?: string } | null;
|
||||
toYard?: { id: string; label?: string; code?: string } | null;
|
||||
wagonType?: { id: string; code?: string; name?: string } | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateTransferRequestPayload {
|
||||
fromYardId: string;
|
||||
toYardId: string;
|
||||
wagonTypeId: string;
|
||||
quantity: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const wagonTransferRequestService = {
|
||||
list: (status?: Freight.WagonTransferRequestStatus) =>
|
||||
apiClient.get<WagonTransferRequest[]>(
|
||||
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
getById: (id: string) =>
|
||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||
create: (data: CreateTransferRequestPayload) =>
|
||||
apiClient.post<WagonTransferRequest>('/wagon-transfer-requests', data),
|
||||
/** OCC: execute the transfer with the hand-picked wagons. */
|
||||
fulfill: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<WagonTransferRequest>(
|
||||
`/wagon-transfer-requests/${id}/fulfill`,
|
||||
{ wagonIds },
|
||||
),
|
||||
cancel: (id: string) =>
|
||||
apiClient.post<WagonTransferRequest>(
|
||||
`/wagon-transfer-requests/${id}/cancel`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -162,6 +162,12 @@ export interface TrainScheduleListItem {
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
freightType?: FreightType | null;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train?: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName?: string | null;
|
||||
} | null;
|
||||
locomotive:
|
||||
| {
|
||||
id: string;
|
||||
@@ -771,8 +777,10 @@ export interface ReschedulePlan {
|
||||
export interface CreateTrainSchedulePayload {
|
||||
routeId: string;
|
||||
scheduleDate: string;
|
||||
/** Locomotives pulling the train (minimum 2 — front and back). */
|
||||
locomotiveIds: string[];
|
||||
/** Built train (Train Builder) to run this departure — its locomotives are used. */
|
||||
trainId?: string;
|
||||
/** Hand-picked locomotives (minimum 2 — front and back). Ignored when trainId is set. */
|
||||
locomotiveIds?: string[];
|
||||
maxTrainWeightTons?: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
|
||||
Reference in New Issue
Block a user