mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
feat(clearance): preview charge documents before and after upload
This commit is contained in:
@@ -6,10 +6,32 @@ import {
|
||||
type DraggableStateSnapshot,
|
||||
type DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
|
||||
import { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { GripVertical, MapPin, Search, Trash2, Wrench, X } from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -41,25 +63,86 @@ function ConsistWagonList({
|
||||
onRemove,
|
||||
onMaintenance,
|
||||
onChangeYard,
|
||||
onChangeYardBulk,
|
||||
busy = false,
|
||||
}: ConsistWagonListProps) {
|
||||
const onDragEnd = useCallback((result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const from = result.source.index;
|
||||
const to = result.destination.index;
|
||||
if (from === to) return;
|
||||
const next = [...wagons];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved!);
|
||||
onReorder(next.map((w) => w.id));
|
||||
}, [wagons, onReorder]);
|
||||
// Multi-select for the bulk yard move. Only offered when the page passes a
|
||||
// bulk handler — otherwise the checkbox column would lead nowhere.
|
||||
const canSelect = Boolean(onChangeYardBulk) && editable;
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkYardId, setBulkYardId] = useState<string | null>(null);
|
||||
const bulkYardsQuery = useQuery(
|
||||
api.routes.yards.queryOptions({
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: canSelect,
|
||||
}),
|
||||
);
|
||||
// A wagon detached elsewhere must not linger in the selection.
|
||||
useEffect(() => {
|
||||
setSelected((prev) => {
|
||||
if (!prev.size) return prev;
|
||||
const live = new Set(wagons.map((w) => w.id));
|
||||
const next = new Set([...prev].filter((id) => live.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [wagons]);
|
||||
const toggleSelected = useCallback((wagonId: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(wagonId)) next.delete(wagonId);
|
||||
else next.add(wagonId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const clearSelection = useCallback(() => setSelected(new Set()), []);
|
||||
const allSelected = canSelect && selected.size === wagons.length && wagons.length > 0;
|
||||
const applyBulkYard = () => {
|
||||
if (!onChangeYardBulk || !bulkYardId || !selected.size) return;
|
||||
onChangeYardBulk([...selected], bulkYardId, () => {
|
||||
clearSelection();
|
||||
setBulkYardId(null);
|
||||
});
|
||||
};
|
||||
// Search never filters: a consist is a physical order, hiding rows would
|
||||
// make position numbers lie. It scrolls the first match into view instead.
|
||||
const [search, setSearch] = useState("");
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const matchId = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return (
|
||||
wagons.find((w) => w.wagonNumber.toLowerCase().includes(q))?.id ?? null
|
||||
);
|
||||
}, [search, wagons]);
|
||||
useEffect(() => {
|
||||
if (!matchId) return;
|
||||
listRef.current
|
||||
?.querySelector(`[data-wagon-id="${matchId}"]`)
|
||||
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}, [matchId]);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const from = result.source.index;
|
||||
const to = result.destination.index;
|
||||
if (from === to) return;
|
||||
const next = [...wagons];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved!);
|
||||
onReorder(next.map((w) => w.id));
|
||||
},
|
||||
[wagons, onReorder],
|
||||
);
|
||||
|
||||
// Legend of the types actually coupled, in consist order — the colour code is
|
||||
// only readable if the row tints are keyed somewhere.
|
||||
const legend = useMemo(
|
||||
() => [
|
||||
...new Map(
|
||||
wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||
wagons
|
||||
.filter((w) => w.wagonType)
|
||||
.map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||
).values(),
|
||||
],
|
||||
[wagons],
|
||||
@@ -74,52 +157,149 @@ function ConsistWagonList({
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
||||
{(dropProvided) => (
|
||||
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{legend.length > 1 ? (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{legend.map((type) => (
|
||||
<Badge
|
||||
key={type.code}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={wagonTypeColor(type.code)}
|
||||
>
|
||||
{type.code} · {type.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
{wagons.map((wagon, index) => (
|
||||
<Draggable
|
||||
key={wagon.id}
|
||||
draggableId={wagon.id}
|
||||
index={index}
|
||||
isDragDisabled={!editable || busy}
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Find wagon number…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
error={
|
||||
search.trim() && !matchId
|
||||
? "No wagon in this consist matches"
|
||||
: undefined
|
||||
}
|
||||
aria-label="Find wagon in consist"
|
||||
/>
|
||||
{canSelect ? (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label={
|
||||
selected.size
|
||||
? `${selected.size} selected`
|
||||
: "Select wagons to move together"
|
||||
}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.size > 0 && !allSelected}
|
||||
disabled={busy}
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? new Set() : new Set(wagons.map((w) => w.id)))
|
||||
}
|
||||
/>
|
||||
{selected.size ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={13} />}
|
||||
onClick={clearSelection}
|
||||
disabled={busy}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
{/* Bulk bar appears only with a selection, so it never competes with the
|
||||
per-wagon yard badge for attention. */}
|
||||
{canSelect && selected.size ? (
|
||||
<Paper withBorder p="xs" radius="md" bg="var(--mantine-color-blue-0)">
|
||||
<Group gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Select
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
label={`Move ${selected.size} wagon${selected.size === 1 ? "" : "s"} to yard`}
|
||||
placeholder="Select yard"
|
||||
data={(bulkYardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
}))}
|
||||
value={bulkYardId}
|
||||
onChange={setBulkYardId}
|
||||
searchable
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<MapPin size={14} />}
|
||||
disabled={busy || !bulkYardId}
|
||||
onClick={applyBulkYard}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
{legend.length > 1 ? (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{legend.map((type) => (
|
||||
<Badge
|
||||
key={type.code}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={wagonTypeColor(type.code)}
|
||||
>
|
||||
{type.code} · {type.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable
|
||||
droppableId="train-consist-wagons"
|
||||
isDropDisabled={!editable || busy}
|
||||
>
|
||||
{(dropProvided) => (
|
||||
<Box
|
||||
ref={listRef}
|
||||
p="xs"
|
||||
style={{
|
||||
maxHeight: 520,
|
||||
overflowY: "auto",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap="xs"
|
||||
ref={dropProvided.innerRef}
|
||||
{...dropProvided.droppableProps}
|
||||
>
|
||||
{(dragProvided, snapshot) => (
|
||||
<WagonRow
|
||||
wagon={wagon}
|
||||
{wagons.map((wagon, index) => (
|
||||
<Draggable
|
||||
key={wagon.id}
|
||||
draggableId={wagon.id}
|
||||
index={index}
|
||||
dragProvided={dragProvided}
|
||||
snapshot={snapshot}
|
||||
editable={editable}
|
||||
busy={busy}
|
||||
onRemove={onRemove}
|
||||
onMaintenance={onMaintenance}
|
||||
onChangeYard={onChangeYard}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{dropProvided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
isDragDisabled={!editable || busy}
|
||||
>
|
||||
{(dragProvided, snapshot) => (
|
||||
<WagonRow
|
||||
wagon={wagon}
|
||||
index={index}
|
||||
dragProvided={dragProvided}
|
||||
snapshot={snapshot}
|
||||
editable={editable}
|
||||
busy={busy}
|
||||
highlighted={wagon.id === matchId}
|
||||
selectable={canSelect}
|
||||
selected={selected.has(wagon.id)}
|
||||
onToggleSelected={toggleSelected}
|
||||
onRemove={onRemove}
|
||||
onMaintenance={onMaintenance}
|
||||
onChangeYard={onChangeYard}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{dropProvided.placeholder}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +315,15 @@ export interface ConsistWagonListProps {
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
|
||||
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
||||
/**
|
||||
* Move every selected wagon to one yard in a single request. Absent hides the
|
||||
* selection column entirely.
|
||||
*/
|
||||
onChangeYardBulk?: (
|
||||
wagonIds: string[],
|
||||
currentYardId: string,
|
||||
onDone: () => void,
|
||||
) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
@@ -148,13 +337,23 @@ function WagonYardBadge({
|
||||
busy: boolean;
|
||||
onChange?: (wagonId: string, currentYardId: string) => void;
|
||||
}) {
|
||||
const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
|
||||
const label =
|
||||
wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
|
||||
const yardsQuery = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }),
|
||||
api.routes.yards.queryOptions({
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: Boolean(onChange),
|
||||
}),
|
||||
);
|
||||
if (!onChange) {
|
||||
return wagon.currentYard ? (
|
||||
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
color="gray"
|
||||
size="xs"
|
||||
radius="sm"
|
||||
leftSection={<MapPin size={10} />}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
) : null;
|
||||
@@ -202,6 +401,10 @@ const WagonRow = memo(function WagonRow({
|
||||
snapshot,
|
||||
editable,
|
||||
busy,
|
||||
highlighted,
|
||||
selectable,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
onRemove,
|
||||
onMaintenance,
|
||||
onChangeYard,
|
||||
@@ -212,6 +415,10 @@ const WagonRow = memo(function WagonRow({
|
||||
snapshot: DraggableStateSnapshot;
|
||||
editable: boolean;
|
||||
busy: boolean;
|
||||
highlighted: boolean;
|
||||
selectable: boolean;
|
||||
selected: boolean;
|
||||
onToggleSelected: (wagonId: string) => void;
|
||||
onRemove: (wagonId: string) => void;
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
||||
@@ -224,6 +431,7 @@ const WagonRow = memo(function WagonRow({
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
data-wagon-id={wagon.id}
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
@@ -237,11 +445,33 @@ const WagonRow = memo(function WagonRow({
|
||||
background: snapshot.isDragging
|
||||
? "white"
|
||||
: `var(--mantine-color-${color}-0)`,
|
||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
|
||||
boxShadow: snapshot.isDragging
|
||||
? "0 8px 24px rgba(0, 0, 0, 0.12)"
|
||||
: highlighted
|
||||
? "0 0 0 3px var(--mantine-color-yellow-4)"
|
||||
: selected
|
||||
? "0 0 0 2px var(--mantine-color-blue-5)"
|
||||
: undefined,
|
||||
cursor: editable
|
||||
? snapshot.isDragging
|
||||
? "grabbing"
|
||||
: "grab"
|
||||
: "default",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{selectable ? (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selected}
|
||||
disabled={busy}
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
// The row is a drag handle — keep the click on the checkbox.
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => onToggleSelected(wagon.id)}
|
||||
/>
|
||||
) : null}
|
||||
{editable ? (
|
||||
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
||||
<GripVertical size={18} />
|
||||
|
||||
Reference in New Issue
Block a user