feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
parent ce3fde676e
commit 4c549029fe
32 changed files with 1195 additions and 660 deletions

View File

@@ -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} />