From 3f03581d8c9f88be03b677fd27454333f4eda8f2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 23:50:35 +0000 Subject: [PATCH] wagon work space, container validation --- .../wagons/WagonYardWorkspaceModal.tsx | 546 +++++++++++------- .../src/features/clearance/requestedCargo.tsx | 89 +++ .../bookings/DocumentClearanceDetailPage.tsx | 38 +- .../contracts/ContractClearanceListPage.tsx | 39 +- 4 files changed, 497 insertions(+), 215 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/features/clearance/requestedCargo.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index ef9621376..67506caed 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -10,15 +10,16 @@ import { Loader, Modal, NumberInput, + Progress, Select, - SimpleGrid, + Slider, Stack, + Switch, Text, ThemeIcon, - Title, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react"; +import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { api } from "@/services/api"; @@ -30,23 +31,93 @@ export interface WagonYardWorkspaceModalProps { onClose: () => void; } -const numberOrZero = (v: number | string): number => { +const AVAILABLE = Freight.WagonStatus.Available; +const ASSIGNED = Freight.WagonStatus.Assigned; + +const clampInt = (v: number | string, max: number): number => { const n = typeof v === "number" ? v : Number(v); - return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; + if (!Number.isFinite(n) || n < 0) return 0; + return Math.min(Math.floor(n), max); }; +/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */ +const QuantityField = ({ + value, + onChange, + max, + disabled, +}: { + value: number; + onChange: (n: number) => void; + max: number; + disabled?: boolean; +}) => { + const set = (v: number | string) => onChange(clampInt(v, max)); + const off = disabled || max === 0; + return ( + + + + `${v}`} + color="edr-green" + /> + + + + + {value > 0 ? ( + + ) : null} + + + ); +}; + +const LegendDot = ({ color, label, value }: { color: string; label: string; value: number }) => ( + + + + {label} + + + {value} + + +); + /** - * 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. + * Bulk yard operations. Pick a yard + wagon type (the two selects filter each + * other to combinations that actually hold stock), read the live Available / + * Assigned split, then move a quantity to another yard or flip a quantity + * between Available and Assigned — replacing one-wagon-at-a-time edits. */ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => { const { toast } = useToast(); - const { data: wagons = [], isLoading: wagonsLoading } = useQuery( - api.wagons.list.queryOptions({ input: {} }), - ); + const { data: wagons = [], isLoading } = useQuery(api.wagons.list.queryOptions({ input: {} })); const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions()); @@ -54,33 +125,35 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const [typeId, setTypeId] = useState(null); const [transferYardId, setTransferYardId] = useState(null); - const [transferQty, setTransferQty] = useState(1); - const [toAssignedQty, setToAssignedQty] = useState(1); - const [toAvailableQty, setToAvailableQty] = useState(1); + const [transferQty, setTransferQty] = useState(0); + const [freeAfterMove, setFreeAfterMove] = useState(false); + const [toAssignedQty, setToAssignedQty] = useState(0); + const [toAvailableQty, setToAvailableQty] = useState(0); const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions()); const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions()); - const yardLabel = useMemo(() => { + const yardName = 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; + const typeInfo = useMemo(() => { + const byId = new Map(wagonTypes.map((t) => [t.id, t])); + return { + label: (id: string) => { + const t = byId.get(id); + return t ? `${t.code}${t.name ? ` - ${t.name}` : ""}` : id; + }, + code: (id: string) => byId.get(id)?.code ?? 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(); for (const w of yardWagons) { @@ -88,9 +161,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro ids.add(w.currentYardId); } return [...ids] - .map((id) => ({ value: id, label: yardLabel(id) })) + .map((id) => ({ value: id, label: yardName(id) })) .sort((a, b) => a.label.localeCompare(b.label)); - }, [yardWagons, typeId, yardLabel]); + }, [yardWagons, typeId, yardName]); const typeOptions = useMemo(() => { const ids = new Set(); @@ -99,36 +172,32 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro ids.add(w.wagonTypeId); } return [...ids] - .map((id) => ({ value: id, label: typeLabel(id) })) + .map((id) => ({ value: id, label: typeInfo.label(id) })) .sort((a, b) => a.label.localeCompare(b.label)); - }, [yardWagons, yardId, typeLabel]); + }, [yardWagons, yardId, typeInfo]); 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), + const availableWagons = useMemo(() => matching.filter((w) => w.status === AVAILABLE), [matching]); + const assignedWagons = useMemo(() => matching.filter((w) => w.status === ASSIGNED), [matching]); + const otherWagons = useMemo( + () => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED), [matching], ); - const assignedWagons = useMemo( - () => matching.filter((w) => w.status === Freight.WagonStatus.Assigned), - [matching], + // Available first, then assigned, then the rest — a partial move relocates + // idle wagons before touching assigned ones. + const transferPool = useMemo( + () => [...availableWagons, ...assignedWagons, ...otherWagons], + [availableWagons, assignedWagons, otherWagons], ); - // 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 otherCount = otherWagons.length; const destinationYardOptions = useMemo( () => @@ -141,15 +210,16 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const bothSelected = Boolean(yardId && typeId); - // Reset the action inputs whenever the yard/type selection changes. + // Reset action inputs when the selection changes. useEffect(() => { setTransferYardId(null); - setTransferQty(1); - setToAssignedQty(1); - setToAvailableQty(1); + setTransferQty(0); + setFreeAfterMove(false); + setToAssignedQty(0); + setToAvailableQty(0); }, [yardId, typeId]); - // Reset the whole workspace when it is reopened. + // Reset the whole workspace when closed. useEffect(() => { if (!opened) { setYardId(null); @@ -157,6 +227,11 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro } }, [opened]); + // Keep quantities within bounds as counts shift after each action. + useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]); + useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]); + useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]); + const showError = (err: unknown, fallback: string) => { const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback; @@ -164,15 +239,22 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro }; const handleTransfer = async () => { - const n = numberOrZero(transferQty); - if (!transferYardId || n < 1) return; - const ids = transferPool.slice(0, n).map((w) => w.id); + if (!transferYardId || transferQty < 1) return; + const ids = transferPool.slice(0, transferQty).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); + if (freeAfterMove) { + await setStatus.mutateAsync({ wagonIds: ids, status: AVAILABLE }); + } + toast({ + title: `Moved ${res.moved} wagon(s) to ${yardName(transferYardId)}${ + freeAfterMove ? " · set Available" : "" + }`, + }); + setTransferQty(0); setTransferYardId(null); + setFreeAfterMove(false); } catch (err) { showError(err, "Transfer failed"); } @@ -180,14 +262,13 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const handleFlip = async ( pool: Wagon[], - qty: number | string, + qty: number, 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 (qty < 1) return; + const ids = pool.slice(0, qty).map((w) => w.id); if (!ids.length) return; try { const res = await setStatus.mutateAsync({ wagonIds: ids, status }); @@ -199,100 +280,141 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro }; const busy = transfer.isPending || setStatus.isPending; + const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0); return (
- Wagon Yard Workspace + Wagon Yard Operations - Move and re-status wagons by yard and type + Move and re-status wagons in bulk — no one-by-one edits
} > - {/* ---- Selectors ---- */} - - - - - + {/* ---- Selection ---- */} + + + + } + nothingFoundMessage="No wagon types here" + radius="md" + /> + + + - {wagonsLoading ? ( + {isLoading ? ( ) : !bothSelected ? ( - - - Select a yard and a wagon type to see how many wagons are there and act on them. - + + + + + + Pick a yard and a wagon type + + You'll see how many wagons of that type sit in that yard, how many are available + vs assigned, and can move or re-status them all at once. + + ) : ( <> - {/* ---- Counts ---- */} - - - - - + {/* ---- Overview hero ---- */} + + + +
+ + {total} + +
+
+ + {typeInfo.code(typeId!)} wagons + + + + {yardName(yardId!)} + +
+
+ + + + {otherCount > 0 ? : null} + +
- + + + {availableCount > 0 ? {availableCount} : null} + + + {assignedCount > 0 ? {assignedCount} : null} + + + {otherCount > 0 ? {otherCount} : null} + + +
- - {/* ---- Transfer ---- */} + {/* ---- Actions ---- */} + + {/* Transfer */} - - + + - Transfer to another yard + Move to another yard - - + +
+ + How many wagons + + +