Files
edr-platform/apps/edr-freight-web/backoffice/src/components/wagons/WagonPicker.tsx
marshalyordanos 5da36eb128 feat: add wagon usage computation and maintenance logging features
- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
2026-08-12 09:36:50 +03:00

210 lines
6.2 KiB
TypeScript

import {
Badge,
Box,
Button,
Checkbox,
Group,
ScrollArea,
Stack,
Text,
TextInput,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { Check, Search, Train, X } from "lucide-react";
import { useMemo, useState } from "react";
import type { Wagon } from "@/services/wagon.service";
export interface WagonPickerProps {
/** The wagons on offer — already filtered to what the yard can hand over. */
wagons: Wagon[];
selected: Set<string>;
onChange: (next: Set<string>) => void;
/** Hard ceiling on the selection; further picks are refused once reached. */
max?: number;
emptyMessage?: string;
maxHeight?: number;
}
/**
* Searchable multi-select over a list of wagons, used wherever staff name the
* physical wagons rather than a count. Search matches the wagon number as typed
* (case-insensitively, ignoring the dashes and spaces operators leave out) so
* "1042" finds "GON-1042".
*/
export function WagonPicker({
wagons,
selected,
onChange,
max,
emptyMessage = "No wagons available here right now.",
maxHeight = 260,
}: WagonPickerProps) {
const [query, setQuery] = useState("");
const normalise = (s: string) => s.toLowerCase().replace(/[\s-]/g, "");
const shown = useMemo(() => {
const q = normalise(query.trim());
if (!q) return wagons;
return wagons.filter((w) => normalise(w.wagonNumber).includes(q));
}, [wagons, query]);
const ceiling = max ?? Number.MAX_SAFE_INTEGER;
const full = selected.size >= ceiling;
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else if (next.size >= ceiling) return; // at the cap — ignore the click
else next.add(id);
onChange(next);
};
/** Select as many of the currently-visible wagons as the cap allows. */
const selectShown = () => {
const next = new Set(selected);
for (const w of shown) {
if (next.size >= ceiling) break;
next.add(w.id);
}
onChange(next);
};
return (
<Stack gap="xs">
<Group gap="xs" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
placeholder="Search wagon number…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<UnstyledButton onClick={() => setQuery("")} aria-label="Clear search">
<X size={14} />
</UnstyledButton>
) : null
}
radius="md"
size="sm"
/>
<Button
size="compact-sm"
variant="light"
radius="md"
onClick={selectShown}
disabled={shown.length === 0 || full}
>
Select {query ? "matches" : "all"}
</Button>
{selected.size > 0 ? (
<Button
size="compact-sm"
variant="subtle"
color="gray"
radius="md"
onClick={() => onChange(new Set())}
>
Clear
</Button>
) : null}
</Group>
<Group gap="xs" justify="space-between">
<Text size="xs" c="dimmed">
{shown.length} of {wagons.length} shown
</Text>
<Badge
variant="light"
color={full ? "orange" : selected.size > 0 ? "teal" : "gray"}
radius="sm"
>
{selected.size} selected{max != null ? ` / ${max}` : ""}
</Badge>
</Group>
{wagons.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
{emptyMessage}
</Text>
) : shown.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
No wagon matches {query}.
</Text>
) : (
<ScrollArea.Autosize mah={maxHeight} type="auto">
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
gap: 8,
paddingRight: 4,
}}
>
{shown.map((w) => {
const on = selected.has(w.id);
// At the cap, unpicked wagons stop responding — grey them out so
// the dead click is explained before it happens.
const blocked = !on && full;
return (
<UnstyledButton
key={w.id}
onClick={() => toggle(w.id)}
disabled={blocked}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 8,
border: `1px solid var(--mantine-color-${
on ? "edr-green-5" : "gray-3"
})`,
background: on
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-body)",
opacity: blocked ? 0.45 : 1,
cursor: blocked ? "not-allowed" : "pointer",
transition: "border-color 120ms, background 120ms",
}}
>
<Checkbox
checked={on}
onChange={() => toggle(w.id)}
disabled={blocked}
size="xs"
color="edr-green"
tabIndex={-1}
styles={{ input: { cursor: blocked ? "not-allowed" : "pointer" } }}
/>
<ThemeIcon
variant="light"
color={on ? "edr-green" : "gray"}
size="sm"
radius="sm"
>
{on ? <Check size={12} /> : <Train size={12} />}
</ThemeIcon>
<Text
size="sm"
fw={on ? 700 : 500}
style={{ fontVariantNumeric: "tabular-nums" }}
truncate
>
{w.wagonNumber}
</Text>
</UnstyledButton>
);
})}
</Box>
</ScrollArea.Autosize>
)}
</Stack>
);
}
export default WagonPicker;