mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
242 lines
7.5 KiB
TypeScript
242 lines
7.5 KiB
TypeScript
import { Freight } from "@edr/types";
|
|
import {
|
|
Badge,
|
|
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,
|
|
exportTrainNumber,
|
|
importTrainNumber,
|
|
}: AvailableWagonsPanelProps) {
|
|
const [search, setSearch] = useState("");
|
|
const [typeFilter, setTypeFilter] = useState<string>("ALL");
|
|
const [runOnly, setRunOnly] = useState(false);
|
|
const [selected, setSelected] = useState<string[]>([]);
|
|
|
|
// The train's own run, e.g. "8001-8002" — only offered when the train has one.
|
|
const runLabel = exportTrainNumber
|
|
? `${exportTrainNumber}${importTrainNumber ? `-${importTrainNumber}` : ""}`
|
|
: null;
|
|
|
|
const wagonsQuery = useQuery(
|
|
api.wagons.list.queryOptions({
|
|
input: {
|
|
filters: {
|
|
status: Freight.WagonStatus.Available,
|
|
currentYardId: yardId,
|
|
// Loose wagons only — one already on another train cannot be coupled.
|
|
unassigned: true,
|
|
},
|
|
},
|
|
enabled: Boolean(yardId),
|
|
}),
|
|
);
|
|
|
|
const wagons = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return (wagonsQuery.data ?? []).filter((wagon) => {
|
|
if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
|
|
// Rostered to this train's run — match on the export run, which fixes the
|
|
// import run anyway.
|
|
if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false;
|
|
if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
|
|
return true;
|
|
});
|
|
}, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]);
|
|
|
|
const runMatchCount = useMemo(
|
|
() =>
|
|
exportTrainNumber
|
|
? (wagonsQuery.data ?? []).filter(
|
|
(w) => w.exportTrainNumber === exportTrainNumber,
|
|
).length
|
|
: 0,
|
|
[wagonsQuery.data, exportTrainNumber],
|
|
);
|
|
|
|
const typeOptions = useMemo(() => {
|
|
const byId = new Map<string, string>();
|
|
for (const wagon of wagonsQuery.data ?? []) {
|
|
if (wagon.wagonType) {
|
|
// e.g. "Flat wagon (NW5)" — name with its type code.
|
|
byId.set(
|
|
wagon.wagonType.id,
|
|
wagon.wagonType.code
|
|
? `${wagon.wagonType.name} (${wagon.wagonType.code})`
|
|
: 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 allSelected =
|
|
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
|
|
const someSelected = wagons.some((w) => selected.includes(w.id));
|
|
|
|
const toggleAll = (checked: boolean) => {
|
|
setSelected((prev) => {
|
|
if (checked) {
|
|
const ids = new Set(prev);
|
|
wagons.forEach((w) => ids.add(w.id));
|
|
return [...ids];
|
|
}
|
|
const visible = new Set(wagons.map((w) => w.id));
|
|
return prev.filter((id) => !visible.has(id));
|
|
});
|
|
};
|
|
|
|
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>
|
|
|
|
{runLabel ? (
|
|
<Checkbox
|
|
size="sm"
|
|
label={`Only wagons on this train's run (${runLabel}) — ${runMatchCount} here`}
|
|
checked={runOnly}
|
|
onChange={(e) => setRunOnly(e.currentTarget.checked)}
|
|
/>
|
|
) : null}
|
|
|
|
{wagons.length ? (
|
|
<Checkbox
|
|
size="sm"
|
|
label={`Select all (${wagons.length})`}
|
|
checked={allSelected}
|
|
indeterminate={!allSelected && someSelected}
|
|
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
|
/>
|
|
) : null}
|
|
|
|
<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 }}>
|
|
<Group gap={6} wrap="nowrap">
|
|
<Text size="sm" fw={600} ff="monospace" truncate>
|
|
{wagon.wagonNumber}
|
|
</Text>
|
|
{wagon.exportTrainNumber ? (
|
|
<Badge
|
|
size="xs"
|
|
radius="sm"
|
|
variant="light"
|
|
color={
|
|
wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"
|
|
}
|
|
>
|
|
{wagon.exportTrainNumber}
|
|
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
<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;
|
|
/** This train's odd EXPORT run — drives the "only this run" filter. */
|
|
exportTrainNumber?: string | null;
|
|
/** This train's even IMPORT run — label only; the export run does the matching. */
|
|
importTrainNumber?: string | null;
|
|
}
|