import { Freight } from "@edr/types"; import { Badge, Box, Button, Card, Grid, Group, Loader, Modal, NumberInput, Progress, Select, Slider, Stack, Text, ThemeIcon, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowRight, ArrowRightLeft, Layers, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import ReactQuill from "react-quill-new"; import "react-quill-new/dist/quill.snow.css"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { Wagon } from "@/services/wagon.service"; import WagonPicker from "./WagonPicker"; const stripHtml = (html: string) => html.replace(/<[^>]*>/g, "").trim(); 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. `max` bounds the field * to the wagons on hand; omitting it leaves the field unbounded and the slider * simply tracks the current value. */ const QuantityField = ({ value, onChange, max, disabled, }: { value: number; onChange: (n: number) => void; max?: number; disabled?: boolean; }) => { const capped = max ?? Number.MAX_SAFE_INTEGER; const set = (v: number | string) => onChange(clampInt(v, capped)); const off = disabled || max === 0; return ( `${v}`} color="edr-green" /> {/* Presets only make sense against a real ceiling — an uncapped request field (transfer ask) shows the manual input alone. */} {max != null ? ( <> ) : null} {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 [transferReason, setTransferReason] = useState(""); /** Specific wagons the requester named — optional; empty means "any N". */ const [pickedWagons, setPickedWagons] = useState>(new Set()); const createRequest = useMutation( api.wagonTransferRequests.create.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]); // Wagons coupled to a built train (trainId set) are managed through the // train-builder flow — they can't be bulk-flipped or transferred here, so // keep them out of the action pools and bucket them under "Other". const availableWagons = useMemo( () => matching.filter((w) => w.status === AVAILABLE && !w.trainId), [matching], ); const assignedWagons = useMemo( () => matching.filter((w) => w.status === ASSIGNED && !w.trainId), [matching], ); const otherWagons = useMemo( () => matching.filter( (w) => w.trainId != null || (w.status !== AVAILABLE && w.status !== ASSIGNED), ), [matching], ); const total = matching.length; const availableCount = availableWagons.length; const assignedCount = assignedWagons.length; const otherCount = otherWagons.length; // Split "Other" so a coupled wagon is visible as such. The Available/Assigned // buckets deliberately count only UNCOUPLED wagons (see above), so a yard // holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" — // accurate for shunting, but unreadable unless the other 53 are named. const onTrainCount = useMemo( () => matching.filter((w) => w.trainId != null).length, [matching], ); 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); setTransferReason(""); setPickedWagons(new Set()); }, [yardId, typeId]); // Naming wagons IS the ask: the count follows the picks so the two can never // disagree. Lowering the count by hand (below) trims the selection instead. const handlePick = (next: Set) => { setPickedWagons(next); if (next.size > 0) setTransferQty(next.size); }; const handleQtyChange = (n: number) => { setTransferQty(n); // Asking for fewer than were picked would send a selection the API rejects // (picks may not exceed quantity) — drop the extras, keeping pick order. if (pickedWagons.size > n) { setPickedWagons(new Set([...pickedWagons].slice(0, n))); } }; // Reset the whole workspace when closed. 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" }); }; // 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 || !stripHtml(transferReason) ) return; try { await createRequest.mutateAsync({ fromYardId: yardId, toYardId: transferYardId, wagonTypeId: typeId, quantity: transferQty, reason: transferReason, ...(pickedWagons.size > 0 ? { preferredWagonIds: [...pickedWagons] } : {}), }); toast({ title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName( yardId, )} → ${yardName(transferYardId)}`, description: pickedWagons.size > 0 ? `OCC will send the ${pickedWagons.size} wagon(s) you named where it can.` : "OCC will pick the wagons and complete the move.", }); setTransferQty(0); setTransferYardId(null); setTransferReason(""); setPickedWagons(new Set()); } catch (err) { showError(err, "Request failed"); } }; const busy = createRequest.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!)}
{onTrainCount > 0 ? ( ) : null} {otherCount - onTrainCount > 0 ? ( ) : null}
{availableCount > 0 ? {availableCount} : null} {assignedCount > 0 ? {assignedCount} : null} {otherCount > 0 ? {otherCount} : null}
{/* ---- Actions ---- */} Request transfer to another yard Sends a request to OCC — they pick the wagons and complete the move.
How many wagons {availableCount} available {/* Capped at the wagons actually available in this yard right now (uncoupled + Available) — a request may not ask for more than the yard can hand over. */}
{/* Optional: name the exact wagons. Leaving this empty files a plain count request and OCC picks whatever is free. */}
Which wagons{" "} (optional) {pickedWagons.size > 0 ? `${pickedWagons.size} named — OCC will prioritise these` : "Leave empty and OCC picks any available"}