Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx
2026-07-27 20:34:14 +00:00

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