mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
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:
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user