Files
edr-platform/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx
Marshal 9b13fa2ac6 feat: implement wagon transfer management modals and page
- Add TransferFulfillModal for fulfilling wagon transfer requests.
- Create TransferRequestFormModal for filing new wagon transfer requests.
- Introduce TransferCloseShortModal for closing requests that cannot be fully fulfilled.
- Develop WagonTransfersPage to manage and display wagon transfer requests.
- Implement utility functions for handling wagon transfer request data and UI components.
- Enhance UI with Mantine components for better user experience.
2026-07-26 15:11:50 +00:00

629 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Freight } from "@edr/types";
import {
Badge,
Box,
Button,
Card,
Divider,
Grid,
Group,
Loader,
Modal,
NumberInput,
Progress,
Select,
Slider,
Stack,
Text,
Textarea,
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. `max` bounds the field
* for actions that move real wagons; omit it for a transfer REQUEST, which may
* legitimately ask for more than the yard holds today (OCC fulfils it in
* instalments) — the slider then just 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 (
<Stack gap={8}>
<Group gap="sm" align="center" wrap="nowrap">
<NumberInput
value={value}
onChange={set}
min={0}
max={max}
allowNegative={false}
clampBehavior="strict"
disabled={off}
radius="md"
w={92}
/>
<Slider
style={{ flex: 1 }}
value={value}
onChange={set}
min={0}
max={Math.max(max ?? Math.max(value, 10), 1)}
disabled={off}
label={(v) => `${v}`}
color="edr-green"
/>
</Group>
<Group gap={6}>
{/* Presets only make sense against a real ceiling — an uncapped request
field (transfer ask) shows the manual input alone. */}
{max != null ? (
<>
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
Half
</Button>
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
All ({max})
</Button>
</>
) : null}
{value > 0 ? (
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
Clear
</Button>
) : null}
</Group>
</Stack>
);
};
const LegendDot = ({ color, label, value }: { color: string; label: string; value: number }) => (
<Group gap={6} wrap="nowrap">
<Box w={10} h={10} style={{ borderRadius: 3, background: `var(--mantine-color-${color}-6)` }} />
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={700}>
{value}
</Text>
</Group>
);
/**
* 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<string | null>(null);
const [typeId, setTypeId] = useState<string | null>(null);
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState(0);
const [transferReason, setTransferReason] = useState("");
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<string>();
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<string>();
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("");
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. A TRANSFER
// REQUEST is deliberately uncapped: OCC delivers in instalments, so asking for
// 50 where 20 sit today is normal — only the status flips below are bounded by
// what is physically in the yard.
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 ||
!transferReason.trim()
)
return;
try {
await createRequest.mutateAsync({
fromYardId: yardId,
toYardId: transferYardId,
wagonTypeId: typeId,
quantity: transferQty,
reason: transferReason.trim(),
});
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);
setTransferReason("");
} 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 (
<Modal
opened={opened}
onClose={onClose}
size="min(1080px, 96vw)"
radius="lg"
centered
overlayProps={{ blur: 2 }}
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Warehouse size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Wagon Yard Operations</Text>
<Text size="xs" c="dimmed">
Move and re-status wagons in bulk no one-by-one edits
</Text>
</div>
</Group>
}
>
<Stack gap="lg">
{/* ---- Selection ---- */}
<Card withBorder radius="md" padding="md" bg="var(--mantine-color-gray-0)">
<Grid gap="md" align="flex-end">
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Yard"
placeholder="Select a yard"
data={yardOptions}
value={yardId}
onChange={setYardId}
searchable
clearable
leftSection={<Warehouse size={16} />}
nothingFoundMessage="No yards with stock"
radius="md"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Wagon type"
placeholder="Select a wagon type"
data={typeOptions}
value={typeId}
onChange={setTypeId}
searchable
clearable
leftSection={<Layers size={16} />}
nothingFoundMessage="No wagon types here"
radius="md"
/>
</Grid.Col>
</Grid>
</Card>
{isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : !bothSelected ? (
<Card withBorder radius="md" padding="xl">
<Stack align="center" gap={6}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Layers size={22} />
</ThemeIcon>
<Text fw={600}>Pick a yard and a wagon type</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
You&apos;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.
</Text>
</Stack>
</Card>
) : (
<>
{/* ---- Overview hero ---- */}
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="lg" align="center" wrap="nowrap">
<div>
<Text size="3rem" fw={800} lh={1}>
{total}
</Text>
</div>
<div>
<Text fw={700} size="lg">
{typeInfo.code(typeId!)} wagons
</Text>
<Group gap={6} c="dimmed">
<Warehouse size={14} />
<Text size="sm">{yardName(yardId!)}</Text>
</Group>
</div>
</Group>
<Group gap="lg" wrap="wrap">
<LegendDot color="teal" label="Available in yard" value={availableCount} />
<LegendDot color="blue" label="Assigned in yard" value={assignedCount} />
{onTrainCount > 0 ? (
<LegendDot color="gray" label="On train" value={onTrainCount} />
) : null}
{otherCount - onTrainCount > 0 ? (
<LegendDot color="gray" label="Other" value={otherCount - onTrainCount} />
) : null}
</Group>
</Group>
<Progress.Root size={22} radius="md" mt="md">
<Progress.Section value={pct(availableCount)} color="teal">
{availableCount > 0 ? <Progress.Label>{availableCount}</Progress.Label> : null}
</Progress.Section>
<Progress.Section value={pct(assignedCount)} color="blue">
{assignedCount > 0 ? <Progress.Label>{assignedCount}</Progress.Label> : null}
</Progress.Section>
<Progress.Section value={pct(otherCount)} color="gray">
{otherCount > 0 ? <Progress.Label>{otherCount}</Progress.Label> : null}
</Progress.Section>
</Progress.Root>
</Card>
{/* ---- Actions ---- */}
<Grid gap="lg">
{/* Transfer */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%" padding="lg">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="grape" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
<Text fw={700}>Request transfer to another yard</Text>
</Group>
<Text size="xs" c="dimmed" mb="md">
Sends a request to OCC they pick the wagons and complete the move.
</Text>
<Stack gap="md">
<div>
<Group justify="space-between" mb={4}>
<Text size="sm" fw={500}>
How many wagons
</Text>
<Badge color="teal" variant="light">
{availableCount} available
</Badge>
</Group>
{/* No max: the request may exceed what the yard holds
today — OCC fulfils it in instalments. */}
<QuantityField value={transferQty} onChange={setTransferQty} />
</div>
<Select
label="Destination yard"
placeholder="Select destination"
data={destinationYardOptions}
value={transferYardId}
onChange={setTransferYardId}
searchable
radius="md"
/>
<Textarea
label="Reason"
placeholder="Why are these wagons needed?"
value={transferReason}
onChange={(e) => setTransferReason(e.currentTarget.value)}
required
autosize
minRows={2}
radius="md"
/>
{transferYardId && transferQty > 0 ? (
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}>
{yardName(yardId!)} {total}
<Text span c="red.6" fw={700}>
{" "}
{transferQty}
</Text>
</Text>
<ArrowRight size={16} />
<Text size="sm" fw={600}>
{yardName(transferYardId)}
<Text span c="teal.7" fw={700}>
{" "}
+{transferQty}
</Text>
</Text>
</Group>
</Card>
) : null}
<Button
leftSection={<ArrowRightLeft size={16} />}
onClick={handleRequest}
loading={createRequest.isPending}
disabled={
busy ||
!transferYardId ||
transferQty < 1 ||
!transferReason.trim()
}
color="edr-green"
>
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
{transferQty === 1 ? "" : "s"}
</Button>
</Stack>
</Card>
</Grid.Col>
{/* Re-status */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%" padding="lg">
<Group gap="xs" mb="md">
<ThemeIcon variant="light" color="orange" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
<Text fw={700}>Change availability</Text>
</Group>
<Stack gap="lg">
<Box>
<Group justify="space-between" mb={6}>
<Group gap={6}>
<ThemeIcon variant="light" color="blue" radius="sm" size="sm">
<CircleSlash size={12} />
</ThemeIcon>
<Text size="sm" fw={600}>
Available Assigned
</Text>
</Group>
<Badge color="teal" variant="light">
{availableCount} free
</Badge>
</Group>
<QuantityField
value={toAssignedQty}
onChange={setToAssignedQty}
max={availableCount}
/>
<Button
mt="sm"
fullWidth
variant="light"
color="blue"
disabled={busy || toAssignedQty < 1}
loading={setStatus.isPending}
onClick={() =>
handleFlip(availableWagons, toAssignedQty, ASSIGNED, "Assigned", () =>
setToAssignedQty(0),
)
}
>
Assign {toAssignedQty > 0 ? `${toAssignedQty} ` : ""}wagon
{toAssignedQty === 1 ? "" : "s"}
</Button>
</Box>
<Divider variant="dashed" />
<Box>
<Group justify="space-between" mb={6}>
<Group gap={6}>
<ThemeIcon variant="light" color="teal" radius="sm" size="sm">
<CheckCircle2 size={12} />
</ThemeIcon>
<Text size="sm" fw={600}>
Assigned Available
</Text>
</Group>
<Badge color="blue" variant="light">
{assignedCount} assigned
</Badge>
</Group>
<QuantityField
value={toAvailableQty}
onChange={setToAvailableQty}
max={assignedCount}
/>
<Button
mt="sm"
fullWidth
variant="light"
color="teal"
disabled={busy || toAvailableQty < 1}
loading={setStatus.isPending}
onClick={() =>
handleFlip(assignedWagons, toAvailableQty, AVAILABLE, "Available", () =>
setToAvailableQty(0),
)
}
>
Free up {toAvailableQty > 0 ? `${toAvailableQty} ` : ""}wagon
{toAvailableQty === 1 ? "" : "s"}
</Button>
</Box>
</Stack>
</Card>
</Grid.Col>
</Grid>
</>
)}
</Stack>
</Modal>
);
};
export default WagonYardWorkspaceModal;