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 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 ---- */}