mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
- 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.
210 lines
6.2 KiB
TypeScript
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;
|