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 yards with stock"
radius="md"
/>
}
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 ---- */}
{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"}