import { Freight } from "@edr/types"; import { Badge, Box, Button, Card, Divider, Grid, Group, Loader, Modal, NumberInput, Progress, Select, Slider, Stack, Text, ThemeIcon, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, 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 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); 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} ); /** * 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 } = 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(null); const [typeId, setTypeId] = useState(null); const [transferYardId, setTransferYardId] = useState(null); const [transferQty, setTransferQty] = useState(0); const [toAssignedQty, setToAssignedQty] = useState(0); const [toAvailableQty, setToAvailableQty] = useState(0); const createRequest = useMutation( api.wagonTransferRequests.create.mutationOptions(), ); const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions()); 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 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]); const yardWagons = useMemo( () => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)), [wagons], ); const yardOptions = useMemo(() => { const ids = new Set(); for (const w of yardWagons) { if (typeId && w.wagonTypeId !== typeId) continue; ids.add(w.currentYardId); } return [...ids] .map((id) => ({ value: id, label: yardName(id) })) .sort((a, b) => a.label.localeCompare(b.label)); }, [yardWagons, typeId, yardName]); const typeOptions = useMemo(() => { const ids = new Set(); for (const w of yardWagons) { if (yardId && w.currentYardId !== yardId) continue; ids.add(w.wagonTypeId); } return [...ids] .map((id) => ({ value: id, label: typeInfo.label(id) })) .sort((a, b) => a.label.localeCompare(b.label)); }, [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 === 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 total = matching.length; const availableCount = availableWagons.length; const assignedCount = assignedWagons.length; const otherCount = otherWagons.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 action inputs when the selection changes. useEffect(() => { setTransferYardId(null); setTransferQty(0); setToAssignedQty(0); setToAvailableQty(0); }, [yardId, typeId]); // Reset the whole workspace when closed. useEffect(() => { if (!opened) { setYardId(null); setTypeId(null); } }, [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; toast({ title: fallback, description: String(message), variant: "destructive" }); }; // Request-only: the requester specifies count + destination; OCC later picks // the physical wagons and executes the move. No wagons are moved here. const handleRequest = async () => { if (!yardId || !typeId || !transferYardId || transferQty < 1) return; try { await createRequest.mutateAsync({ fromYardId: yardId, toYardId: transferYardId, wagonTypeId: typeId, quantity: transferQty, }); toast({ title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName( yardId, )} → ${yardName(transferYardId)}`, description: "OCC will pick the wagons and complete the move.", }); setTransferQty(0); setTransferYardId(null); } catch (err) { showError(err, "Request failed"); } }; const handleFlip = async ( pool: Wagon[], qty: number, status: Freight.WagonStatus, label: string, reset: () => void, ) => { 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 }); toast({ title: `${res.updated} wagon(s) set to ${label}` }); reset(); } catch (err) { showError(err, "Status update failed"); } }; const busy = createRequest.isPending || setStatus.isPending; const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0); return (
Wagon Yard Operations Move and re-status wagons in bulk — no one-by-one edits
} > {/* ---- Selection ---- */} } nothingFoundMessage="No wagon types here" radius="md" /> {isLoading ? ( ) : !bothSelected ? ( 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. ) : ( <> {/* ---- Overview hero ---- */}
{total}
{typeInfo.code(typeId!)} wagons {yardName(yardId!)}
{otherCount > 0 ? : null}
{availableCount > 0 ? {availableCount} : null} {assignedCount > 0 ? {assignedCount} : null} {otherCount > 0 ? {otherCount} : null}
{/* ---- Actions ---- */} {/* Transfer */} Request transfer to another yard Sends a request to OCC — they pick the wagons and complete the move.
How many wagons