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