Merge pull request #1329 from Tria-plc/freight_feature/usermanagement

feat(freight): offer built-train wagons per boarding yard on multi-ya…
This commit is contained in:
marshal
2026-08-18 11:28:32 +03:00
committed by GitHub
33 changed files with 1234 additions and 231 deletions

View File

@@ -562,6 +562,11 @@ export const buildSidebarSections = (
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.settings.exchangeRate.view,
},
{
label: "Manual payments",
href: "/dashboard/configuration/manual-payments",
permission: FREIGHT_PERMS.settings.manualPayment.view,
},
],
},
{

View File

@@ -4,34 +4,40 @@ import {
Button,
Checkbox,
Group,
Pagination,
ScrollArea,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { MapPin, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
const PAGE_SIZE = 20;
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.
* AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
* so the picker never page-walks the whole fleet into the browser.
*/
export default function AvailableWagonsPanel({
yardId,
yardLabel,
homeYardId,
onAssign,
assigning,
exportTrainNumber,
importTrainNumber,
}: AvailableWagonsPanelProps) {
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [yardFilter, setYardFilter] = useState<string>("ALL");
const [runOnly, setRunOnly] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [page, setPage] = useState(1);
// The train's own run, e.g. "8001-8002" — only offered when the train has one.
const runLabel = exportTrainNumber
@@ -39,59 +45,65 @@ export default function AvailableWagonsPanel({
: null;
const wagonsQuery = useQuery(
api.wagons.list.queryOptions({
api.wagons.listPaged.queryOptions({
input: {
filters: {
status: Freight.WagonStatus.Available,
currentYardId: yardId,
// Loose wagons only — one already on another train cannot be coupled.
unassigned: true,
search: debouncedSearch.trim() || undefined,
currentYardId: yardFilter === "ALL" ? undefined : yardFilter,
wagonTypeId: typeFilter === "ALL" ? undefined : typeFilter,
// Rostered to this train's run — the API matches either run column.
trainNumber: runOnly && exportTrainNumber ? exportTrainNumber : undefined,
page,
pageSize: PAGE_SIZE,
},
},
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 wagons = wagonsQuery.data?.items ?? [];
const total = wagonsQuery.data?.meta.total ?? 0;
const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1);
const runMatchCount = useMemo(
() =>
exportTrainNumber
? (wagonsQuery.data ?? []).filter(
(w) => w.exportTrainNumber === exportTrainNumber,
).length
: 0,
[wagonsQuery.data, exportTrainNumber],
);
// Filters change → back to page 1 (and clamp when the list shrinks).
useEffect(() => {
setPage(1);
}, [debouncedSearch, typeFilter, yardFilter, runOnly]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
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,
);
}
}
// Dropdowns come from the reference lists, not the current page — a yard or
// type must stay pickable even when this page holds none of it.
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 }));
const yardOptions = useMemo(() => {
const yards = [...(yardsQuery.data ?? [])].sort((a, b) =>
a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label),
);
return [
{ value: "ALL", label: "All types" },
...[...byId.entries()].map(([value, label]) => ({ value, label })),
{ value: "ALL", label: "All yards" },
...yards.map((yard) => ({
value: yard.id,
label: `${yard.label}${yard.id === homeYardId ? " · train's yard" : ""}`,
})),
];
}, [wagonsQuery.data]);
}, [yardsQuery.data, homeYardId]);
const typeOptions = useMemo(
() => [
{ value: "ALL", label: "All types" },
// e.g. "Flat wagon (NW5)" — name with its type code.
...(wagonTypesQuery.data ?? []).map((type) => ({
value: type.id,
label: type.code ? `${type.name} (${type.code})` : type.name,
})),
],
[wagonTypesQuery.data],
);
const toggle = (wagonId: string, checked: boolean) => {
setSelected((prev) =>
@@ -99,8 +111,8 @@ export default function AvailableWagonsPanel({
);
};
const allSelected =
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
// Select-all covers this page only — the rest of the matches are not loaded.
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) => {
@@ -138,24 +150,40 @@ export default function AvailableWagonsPanel({
onChange={(v) => setTypeFilter(v ?? "ALL")}
/>
</Group>
<Select
size="sm"
leftSection={<MapPin size={14} />}
data={yardOptions}
value={yardFilter}
onChange={(v) => setYardFilter(v ?? "ALL")}
searchable
aria-label="Filter by yard"
/>
{runLabel ? (
<Checkbox
size="sm"
label={`Only wagons on this train's run (${runLabel})${runMatchCount} here`}
label={`Only wagons on this train's run (${runLabel})`}
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)}
/>
<Group justify="space-between" wrap="nowrap">
<Checkbox
size="sm"
label={`Select all on this page (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
{selected.length ? (
<Text size="xs" c="dimmed">
{selected.length} selected
</Text>
) : null}
</Group>
) : null}
<ScrollArea.Autosize mah={380} type="auto">
@@ -166,7 +194,7 @@ export default function AvailableWagonsPanel({
</Text>
) : !wagons.length ? (
<Text py="md" ta="center" c="dimmed" size="sm">
No available wagons in {yardLabel ?? "this yard"}
No available wagons match
</Text>
) : (
wagons.map((wagon) => (
@@ -191,6 +219,15 @@ export default function AvailableWagonsPanel({
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Badge
size="xs"
radius="sm"
variant="outline"
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
leftSection={<MapPin size={10} />}
>
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
</Badge>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
@@ -217,6 +254,15 @@ export default function AvailableWagonsPanel({
</Stack>
</ScrollArea.Autosize>
{totalPages > 1 ? (
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, total)} of {total}
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
<Button
leftSection={<Plus size={16} />}
disabled={!selected.length}
@@ -230,8 +276,8 @@ export default function AvailableWagonsPanel({
}
export interface AvailableWagonsPanelProps {
yardId: string;
yardLabel?: string | null;
/** The train's own yard — sorted first and highlighted; not a restriction. */
homeYardId: string | null;
onAssign: (wagonIds: string[]) => void;
assigning: boolean;
/** This train's odd EXPORT run — drives the "only this run" filter. */

View File

@@ -7,7 +7,7 @@ import {
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2, Wrench } from "lucide-react";
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
@@ -191,6 +191,11 @@ function WagonRow({
{wagon.wagonType.code}
</Badge>
) : null}
{wagon.currentYard ? (
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
{wagon.currentYard.label ?? wagon.currentYard.code}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType