wagon work space, container validation

This commit is contained in:
Marshal
2026-07-10 23:29:28 +00:00
parent 5d8658b5d6
commit dd87c2a722
12 changed files with 878 additions and 47 deletions

View File

@@ -0,0 +1,441 @@
import { Freight } from "@edr/types";
import {
Badge,
Box,
Button,
Card,
Divider,
Grid,
Group,
Loader,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { Wagon } from "@/services/wagon.service";
export interface WagonYardWorkspaceModalProps {
opened: boolean;
onClose: () => void;
}
const numberOrZero = (v: number | string): number => {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
};
/**
* Yard workspace: pick a yard + wagon type (the two selects filter each other
* to only in-inventory combinations), see how many wagons of that type sit in
* that yard and how they split Available / Assigned, then bulk-transfer a
* quantity to another yard or flip a quantity between Available and Assigned.
*/
const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => {
const { toast } = useToast();
const { data: wagons = [], isLoading: wagonsLoading } = useQuery(
api.wagons.list.queryOptions({ input: {} }),
);
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
const [yardId, setYardId] = useState<string | null>(null);
const [typeId, setTypeId] = useState<string | null>(null);
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState<number | string>(1);
const [toAssignedQty, setToAssignedQty] = useState<number | string>(1);
const [toAvailableQty, setToAvailableQty] = useState<number | string>(1);
const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions());
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
const yardLabel = useMemo(() => {
const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
return (id: string) => byId.get(id) ?? id;
}, [yards]);
const typeLabel = useMemo(() => {
const byId = new Map(
wagonTypes.map((t) => [t.id, `${t.code}${t.name ? ` - ${t.name}` : ""}`]),
);
return (id: string) => byId.get(id) ?? id;
}, [wagonTypes]);
// Only wagons that currently sit in a yard participate in the workspace.
const yardWagons = useMemo(
() => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)),
[wagons],
);
// Each select is constrained by the other's current value so only real
// (yard, type) combinations that hold stock can be picked.
const yardOptions = useMemo(() => {
const ids = new Set<string>();
for (const w of yardWagons) {
if (typeId && w.wagonTypeId !== typeId) continue;
ids.add(w.currentYardId);
}
return [...ids]
.map((id) => ({ value: id, label: yardLabel(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [yardWagons, typeId, yardLabel]);
const typeOptions = useMemo(() => {
const ids = new Set<string>();
for (const w of yardWagons) {
if (yardId && w.currentYardId !== yardId) continue;
ids.add(w.wagonTypeId);
}
return [...ids]
.map((id) => ({ value: id, label: typeLabel(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [yardWagons, yardId, typeLabel]);
const matching = useMemo(() => {
if (!yardId || !typeId) return [] as Wagon[];
return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId);
}, [yardWagons, yardId, typeId]);
const availableWagons = useMemo(
() => matching.filter((w) => w.status === Freight.WagonStatus.Available),
[matching],
);
const assignedWagons = useMemo(
() => matching.filter((w) => w.status === Freight.WagonStatus.Assigned),
[matching],
);
// Available first so a partial transfer moves idle wagons before assigned ones.
const transferPool = useMemo(() => {
const rest = matching.filter(
(w) =>
w.status !== Freight.WagonStatus.Available &&
w.status !== Freight.WagonStatus.Assigned,
);
return [...availableWagons, ...assignedWagons, ...rest];
}, [matching, availableWagons, assignedWagons]);
const total = matching.length;
const availableCount = availableWagons.length;
const assignedCount = assignedWagons.length;
const destinationYardOptions = useMemo(
() =>
yards
.filter((y) => y.id !== yardId)
.map((y) => ({ value: y.id, label: y.label || y.code || y.id }))
.sort((a, b) => a.label.localeCompare(b.label)),
[yards, yardId],
);
const bothSelected = Boolean(yardId && typeId);
// Reset the action inputs whenever the yard/type selection changes.
useEffect(() => {
setTransferYardId(null);
setTransferQty(1);
setToAssignedQty(1);
setToAvailableQty(1);
}, [yardId, typeId]);
// Reset the whole workspace when it is reopened.
useEffect(() => {
if (!opened) {
setYardId(null);
setTypeId(null);
}
}, [opened]);
const showError = (err: unknown, fallback: string) => {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
toast({ title: fallback, description: String(message), variant: "destructive" });
};
const handleTransfer = async () => {
const n = numberOrZero(transferQty);
if (!transferYardId || n < 1) return;
const ids = transferPool.slice(0, n).map((w) => w.id);
if (!ids.length) return;
try {
const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId });
toast({ title: `Transferred ${res.moved} wagon(s) to ${yardLabel(transferYardId)}` });
setTransferQty(1);
setTransferYardId(null);
} catch (err) {
showError(err, "Transfer failed");
}
};
const handleFlip = async (
pool: Wagon[],
qty: number | string,
status: Freight.WagonStatus,
label: string,
reset: () => void,
) => {
const n = numberOrZero(qty);
if (n < 1) return;
const ids = pool.slice(0, n).map((w) => w.id);
if (!ids.length) return;
try {
const res = await setStatus.mutateAsync({ wagonIds: ids, status });
toast({ title: `${res.updated} wagon(s) set to ${label}` });
reset();
} catch (err) {
showError(err, "Status update failed");
}
};
const busy = transfer.isPending || setStatus.isPending;
return (
<Modal
opened={opened}
onClose={onClose}
size="min(1040px, 96vw)"
radius="lg"
centered
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Warehouse size={18} />
</ThemeIcon>
<div>
<Text fw={600}>Wagon Yard Workspace</Text>
<Text size="xs" c="dimmed">
Move and re-status wagons by yard and type
</Text>
</div>
</Group>
}
>
<Stack gap="lg">
{/* ---- Selectors ---- */}
<Grid gutter="md">
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Yard"
placeholder="Select a yard"
data={yardOptions}
value={yardId}
onChange={setYardId}
searchable
clearable
nothingFoundMessage="No yards with stock"
radius="md"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Wagon type"
placeholder="Select a wagon type"
data={typeOptions}
value={typeId}
onChange={setTypeId}
searchable
clearable
nothingFoundMessage="No wagon types here"
radius="md"
/>
</Grid.Col>
</Grid>
{wagonsLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : !bothSelected ? (
<Card withBorder radius="md" bg="var(--mantine-color-gray-0)">
<Text size="sm" c="dimmed" ta="center" py="lg">
Select a yard and a wagon type to see how many wagons are there and act on them.
</Text>
</Card>
) : (
<>
{/* ---- Counts ---- */}
<SimpleGrid cols={{ base: 3 }} spacing="md">
<StatCard label="In yard" value={total} color="gray" />
<StatCard label="Available" value={availableCount} color="teal" />
<StatCard label="Assigned" value={assignedCount} color="blue" />
</SimpleGrid>
<Divider />
<Grid gutter="lg">
{/* ---- Transfer ---- */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%">
<Group gap="xs" mb="sm">
<ThemeIcon variant="light" color="grape" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
<Title order={5}>Transfer to another yard</Title>
</Group>
<Stack gap="sm">
<NumberInput
label="How many wagons"
min={1}
max={total}
value={transferQty}
onChange={setTransferQty}
disabled={total === 0}
radius="md"
/>
<Select
label="Destination yard"
placeholder="Select destination"
data={destinationYardOptions}
value={transferYardId}
onChange={setTransferYardId}
searchable
radius="md"
/>
<Button
leftSection={<ArrowRightLeft size={16} />}
onClick={handleTransfer}
loading={transfer.isPending}
disabled={busy || !transferYardId || numberOrZero(transferQty) < 1 || total === 0}
>
Transfer
</Button>
<Text size="xs" c="dimmed">
Available wagons move first. Up to {total} can be transferred.
</Text>
</Stack>
</Card>
</Grid.Col>
{/* ---- Re-status ---- */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%">
<Group gap="xs" mb="sm">
<ThemeIcon variant="light" color="orange" radius="md" size="md">
<Repeat size={16} />
</ThemeIcon>
<Title order={5}>Change status</Title>
</Group>
<Stack gap="md">
<Box>
<Group justify="space-between" mb={4}>
<Text size="sm" fw={500}>
Available Assigned
</Text>
<Badge color="teal" variant="light">
{availableCount} available
</Badge>
</Group>
<Group align="flex-end" gap="sm">
<NumberInput
min={1}
max={availableCount}
value={toAssignedQty}
onChange={setToAssignedQty}
disabled={availableCount === 0}
radius="md"
style={{ flex: 1 }}
/>
<Button
variant="light"
color="blue"
leftSection={<PackageCheck size={16} />}
loading={setStatus.isPending}
disabled={busy || availableCount === 0 || numberOrZero(toAssignedQty) < 1}
onClick={() =>
handleFlip(
availableWagons,
toAssignedQty,
Freight.WagonStatus.Assigned,
"Assigned",
() => setToAssignedQty(1),
)
}
>
Assign
</Button>
</Group>
</Box>
<Divider variant="dashed" />
<Box>
<Group justify="space-between" mb={4}>
<Text size="sm" fw={500}>
Assigned Available
</Text>
<Badge color="blue" variant="light">
{assignedCount} assigned
</Badge>
</Group>
<Group align="flex-end" gap="sm">
<NumberInput
min={1}
max={assignedCount}
value={toAvailableQty}
onChange={setToAvailableQty}
disabled={assignedCount === 0}
radius="md"
style={{ flex: 1 }}
/>
<Button
variant="light"
color="teal"
leftSection={<PackageCheck size={16} />}
loading={setStatus.isPending}
disabled={busy || assignedCount === 0 || numberOrZero(toAvailableQty) < 1}
onClick={() =>
handleFlip(
assignedWagons,
toAvailableQty,
Freight.WagonStatus.Available,
"Available",
() => setToAvailableQty(1),
)
}
>
Free up
</Button>
</Group>
</Box>
</Stack>
</Card>
</Grid.Col>
</Grid>
</>
)}
</Stack>
</Modal>
);
};
const StatCard = ({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) => (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="1.75rem" fw={700} c={`${color}.7`} lh={1.1} mt={4}>
{value}
</Text>
</Card>
);
export default WagonYardWorkspaceModal;