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.
This commit is contained in:
Marshal
2026-07-26 15:11:50 +00:00
parent 9a1c8e5603
commit 9b13fa2ac6
40 changed files with 2584 additions and 809 deletions

View File

@@ -1,4 +1,5 @@
import {
ArrowLeftRight,
Boxes,
Building2,
Container,
@@ -86,6 +87,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
@@ -297,6 +299,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Truck />,
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Wagon Transfers",
href: "/dashboard/wagon-transfers",
icon: <ArrowLeftRight />,
permission: [
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
],
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
@@ -1180,6 +1191,19 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="wagon-transfers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
]}
>
<WagonTransfersPage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
@@ -1410,6 +1434,19 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="wagon-transfers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
]}
>
<WagonTransfersPage />
</RequirePermission>
}
/>
<Route
path="containers"
element={

View File

@@ -147,7 +147,7 @@ export const BookingDetailModal = ({
<Group gap={4} justify="flex-end">
{bookingWagons.map((w) => (
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
#{w.sequenceNo}
#{w.position ?? w.sequenceNo}
</Badge>
))}
</Group>

View File

@@ -281,7 +281,7 @@ function WagonCar({
{/* header */}
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
<Text size="10px" fw={800} c="gray.7">
#{wagon.sequenceNo}
#{wagon.position ?? wagon.sequenceNo}
</Text>
{isEmpty ? (
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
@@ -425,7 +425,7 @@ function WagonCar({
</Box>
<div>
<Text size="sm" fw={800}>
Wagon #{wagon.sequenceNo}
Wagon #{wagon.position ?? wagon.sequenceNo}
</Text>
<Text size="10px" c="dimmed">
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}

View File

@@ -45,7 +45,7 @@ export const RemoveBookingModal = ({
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
</Text>
<Text size="sm">
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
<strong>Wagon Slot:</strong> #{wagon.position ?? wagon.sequenceNo}
</Text>
</Stack>
</div>

View File

@@ -217,7 +217,7 @@ export const TrainConsistView = ({
<Box>
<Group gap={6} mb={6} wrap="nowrap">
<Badge variant="light" color="edr-green" radius="sm">
Editing wagon #{selectedWagon.sequenceNo}
Editing wagon #{selectedWagon.position ?? selectedWagon.sequenceNo}
</Badge>
<Text size="xs" c="dimmed">
Update container numbers, move containers to another wagon, or remove the booking

View File

@@ -80,7 +80,7 @@ export const WagonCard = ({
<div>
<Group gap={4}>
<Text size="sm" fw={800}>
Wagon #{wagon.sequenceNo}
Wagon #{wagon.position ?? wagon.sequenceNo}
</Text>
<Badge size="xs" variant="light" color="edr-green">
{wagonType}
@@ -205,7 +205,7 @@ export const WagonCard = ({
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Text size="xs" fw={600} truncate>
#{w.sequenceNo} ·{" "}
#{w.position ?? w.sequenceNo} ·{" "}
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
</Text>
<Badge

View File

@@ -1,613 +0,0 @@
import { Freight } from "@edr/types";
import {
Badge,
Button,
Card,
Checkbox,
Divider,
Group,
Loader,
Modal,
ScrollArea,
Stack,
Switch,
Tabs,
Text,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ChevronLeft,
History,
Inbox,
PackageCheck,
Warehouse,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import type {
WagonMovementRecord,
WagonTransferRequest,
} from "@/services/wagon.service";
export interface WagonTransferRequestsModalProps {
opened: boolean;
onClose: () => void;
}
const PENDING = Freight.WagonTransferRequestStatus.Pending;
const AVAILABLE = Freight.WagonStatus.Available;
const yardLabel = (y?: { label?: string; code?: string } | null) =>
y?.label || y?.code || "—";
const typeLabel = (t?: { code?: string; name?: string } | null) =>
t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—";
/** Requester → destination + type + count summary line, reused in list and picker. */
const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
<Group gap={8} wrap="nowrap">
<Text fw={600} size="sm" truncate>
{yardLabel(r.fromYard)}
</Text>
<ArrowRight size={14} style={{ flexShrink: 0 }} />
<Text fw={600} size="sm" truncate>
{yardLabel(r.toYard)}
</Text>
<Badge variant="light" color="grape" radius="sm">
{r.quantity}× {typeLabel(r.wagonType)}
</Badge>
</Group>
);
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
FULFILLED: "teal",
CANCELLED: "red",
};
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/**
* Per-user transfer history. A staffer sees their OWN activity — the requests
* they filed or fulfilled, and the individual wagons they moved. Holders of
* `transfer_history_all` get an "All staff" toggle that widens the view; the
* backend enforces the scope regardless of the toggle.
*/
function HistoryPanel({ opened }: { opened: boolean }) {
const { user } = useAuth();
const canSeeAll = hasPermission(
user,
FREIGHT_PERMS.wagons.transferHistoryAll,
);
const myId = (user as { id?: string } | null | undefined)?.id;
const [allStaff, setAllStaff] = useState(false);
const scopeAll = canSeeAll && allStaff;
const mine = useQuery({
...api.wagonTransferRequests.history.queryOptions(),
enabled: opened && !scopeAll,
});
const all = useQuery({
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
enabled: opened && scopeAll,
});
const source = scopeAll ? all : mine;
const requests = source.data?.requests ?? [];
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
const roleBadge = (r: WagonTransferRequest) => {
if (myId && r.fulfilledByUserId === myId)
return (
<Badge size="xs" variant="light" color="blue">
fulfilled
</Badge>
);
if (myId && r.requestedByUserId === myId)
return (
<Badge size="xs" variant="light" color="grape">
requested
</Badge>
);
return null;
};
return (
<Stack gap="lg">
{canSeeAll ? (
<Group justify="flex-end">
<Switch
checked={allStaff}
onChange={(e) => setAllStaff(e.currentTarget.checked)}
label="All staff"
color="edr-green"
/>
</Group>
) : null}
{source.isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : (
<>
<div>
<Text fw={700} size="sm" mb={8}>
Requests{scopeAll ? "" : " you touched"}
</Text>
{requests.length === 0 ? (
<Text size="sm" c="dimmed">
No requests yet.
</Text>
) : (
<Stack gap={6}>
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="xs">
<Group justify="space-between" wrap="nowrap">
<RequestSummary r={r} />
<Group gap={8} wrap="nowrap">
{roleBadge(r)}
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status] ?? "gray"}
>
{r.status.toLowerCase()}
</Badge>
</Group>
</Group>
{r.reason ? (
<Text size="xs" c="dimmed" mt={4}>
Reason: {r.reason}
</Text>
) : null}
</Card>
))}
</Stack>
)}
</div>
<Divider />
<div>
<Text fw={700} size="sm" mb={8}>
Wagons moved
</Text>
{movements.length === 0 ? (
<Text size="sm" c="dimmed">
No wagon moves yet.
</Text>
) : (
<ScrollArea.Autosize mah={260}>
<Stack gap={6}>
{movements.map((m) => (
<Card key={m.id} withBorder radius="md" padding="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm">
{m.wagon?.wagonNumber ?? "Wagon"}
</Text>
<Text size="xs" c="dimmed" truncate>
{yardLabel(m.fromYard)} {yardLabel(m.toYard)}
</Text>
{m.transferRequestId ? (
<Badge size="xs" variant="light" color="teal">
from request
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{fmtDateTime(m.occurredAt)}
</Text>
</Group>
</Card>
))}
</Stack>
</ScrollArea.Autosize>
)}
</div>
</>
)}
</Stack>
);
}
/**
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
* one to hand-pick exactly the requested number of wagons from the source yard
* (of the requested type) and execute the move, or cancel the request.
* A second tab shows per-user transfer history.
*/
const WagonTransferRequestsModal = ({
opened,
onClose,
}: WagonTransferRequestsModalProps) => {
const { toast } = useToast();
const [tab, setTab] = useState<string | null>("queue");
const [active, setActive] = useState<WagonTransferRequest | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
// Bulk accept-and-execute: the subset of pending requests OCC ticked.
const [selected, setSelected] = useState<Set<string>>(new Set());
const { data: requests = [], isLoading } = useQuery({
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
enabled: opened,
});
// Available wagons of the requested type sitting in the request's source yard.
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
...api.wagons.list.queryOptions({
input: {
filters: active
? {
currentYardId: active.fromYardId,
wagonTypeId: active.wagonTypeId,
status: AVAILABLE,
}
: {},
},
}),
enabled: opened && Boolean(active),
});
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
const bulkFulfill = useMutation(
api.wagonTransferRequests.bulkFulfill.mutationOptions(),
);
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
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" });
};
const openPicker = (r: WagonTransferRequest) => {
setActive(r);
setPicked(new Set());
};
const closePicker = () => {
setActive(null);
setPicked(new Set());
};
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (active && next.size >= active.quantity) return prev; // cap at quantity
else next.add(id);
return next;
});
const need = active?.quantity ?? 0;
const shortfall = active ? Math.max(0, need - wagons.length) : 0;
const handleFulfill = async () => {
if (!active || picked.size !== need) return;
try {
await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] });
toast({
title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)}${yardLabel(
active.toYard,
)}`,
});
closePicker();
} catch (err) {
showError(err, "Transfer failed");
}
};
const handleCancel = async (r: WagonTransferRequest) => {
try {
await cancel.mutateAsync({ id: r.id });
toast({ title: "Request cancelled" });
} catch (err) {
showError(err, "Cancel failed");
}
};
const toggleSelected = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Execute the ticked subset; whatever cannot run (not enough available
// wagons, already decided) is reported and simply stays PENDING.
const handleBulkFulfill = async () => {
if (selected.size === 0) return;
try {
const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
setSelected(new Set());
const skippedNote = res.skipped.length
? ` · ${res.skipped.length} left pending (${res.skipped
.map((s) => s.reason)
.join('; ')})`
: "";
toast({
title: `Executed ${res.fulfilled.length} transfer request(s)`,
description: skippedNote || undefined,
variant: res.fulfilled.length === 0 ? "destructive" : undefined,
});
} catch (err) {
showError(err, "Bulk execute failed");
}
};
const sortedWagons = useMemo(
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
[wagons],
);
return (
<Modal
opened={opened}
onClose={onClose}
size="min(760px, 96vw)"
radius="lg"
centered
overlayProps={{ blur: 2 }}
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Inbox size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Wagon Transfer Requests</Text>
<Text size="xs" c="dimmed">
{active
? "Pick the wagons to move, then transfer"
: "OCC queue — pick wagons and complete each move"}
</Text>
</div>
</Group>
}
>
<Tabs value={tab} onChange={setTab} keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
Queue
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="queue">
{!active ? (
// ---- Pending queue ----
isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : requests.length === 0 ? (
<Card withBorder radius="md" padding="xl">
<Stack align="center" gap={6}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text fw={600}>No pending transfer requests</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
When staff request a yard-to-yard wagon move, it appears here for
you to fulfil.
</Text>
</Stack>
</Card>
) : (
<Stack gap="sm">
{/* Bulk accept-and-execute action bar: tick a subset, run it, and
everything unticked (or unexecutable) stays PENDING. */}
<Group justify="space-between" wrap="nowrap">
<Checkbox
label={
selected.size > 0
? `${selected.size} of ${requests.length} selected`
: "Select all"
}
checked={selected.size === requests.length && requests.length > 0}
indeterminate={selected.size > 0 && selected.size < requests.length}
onChange={() =>
setSelected(
selected.size === requests.length
? new Set()
: new Set(requests.map((r) => r.id)),
)
}
color="edr-green"
/>
<Button
size="compact-sm"
color="edr-green"
leftSection={<PackageCheck size={14} />}
loading={bulkFulfill.isPending}
disabled={selected.size === 0}
onClick={handleBulkFulfill}
>
Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</Group>
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<Checkbox
checked={selected.has(r.id)}
onChange={() => toggleSelected(r.id)}
color="edr-green"
mt={2}
/>
<Stack gap={6} style={{ minWidth: 0 }}>
<RequestSummary r={r} />
{r.reason ? (
<Text size="xs">
<Text span fw={600}>
Reason:
</Text>{" "}
{r.reason}
</Text>
) : null}
{r.note ? (
<Text size="xs" c="dimmed">
{r.note}
</Text>
) : null}
</Stack>
</Group>
<Group gap={8} wrap="nowrap">
<Button
size="compact-sm"
variant="subtle"
color="gray"
leftSection={<X size={14} />}
loading={cancel.isPending}
onClick={() => handleCancel(r)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="edr-green"
leftSection={<PackageCheck size={14} />}
onClick={() => openPicker(r)}
>
Fulfil
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
)
) : (
// ---- Wagon picker for the active request ----
<Stack gap="md">
<Card withBorder radius="md" padding="sm" bg="var(--mantine-color-gray-0)">
<RequestSummary r={active} />
</Card>
<Group justify="space-between">
<Text size="sm" fw={600}>
Select wagons in {yardLabel(active.fromYard)}
</Text>
<Badge
color={picked.size === need ? "teal" : "gray"}
variant={picked.size === need ? "filled" : "light"}
>
{picked.size} / {need} selected
</Badge>
</Group>
{wagonsLoading ? (
<Group justify="center" p="lg">
<Loader size="sm" />
</Group>
) : sortedWagons.length === 0 ? (
<Card withBorder radius="md" padding="lg">
<Group gap={8} justify="center">
<Warehouse size={16} />
<Text size="sm" c="dimmed">
No available wagons of this type in {yardLabel(active.fromYard)}.
</Text>
</Group>
</Card>
) : (
<>
{shortfall > 0 ? (
<Text size="xs" c="orange.7">
Only {sortedWagons.length} available {shortfall} short of the{" "}
{need} requested.
</Text>
) : null}
<ScrollArea.Autosize mah={320}>
<Stack gap={6}>
{sortedWagons.map((w) => {
const checked = picked.has(w.id);
const atCap = !checked && picked.size >= need;
return (
<Card
key={w.id}
withBorder
radius="md"
padding="xs"
onClick={() => !atCap && toggle(w.id)}
style={{
cursor: atCap ? "not-allowed" : "pointer",
borderColor: checked
? "var(--mantine-color-edr-green-4)"
: undefined,
opacity: atCap ? 0.55 : 1,
}}
>
<Group gap="sm" wrap="nowrap">
{/* Visual only — the Card's onClick owns the toggle so a
click on the box doesn't fire both and cancel out. */}
<Checkbox
checked={checked}
readOnly
disabled={atCap}
color="edr-green"
tabIndex={-1}
aria-hidden
/>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Group>
</Card>
);
})}
</Stack>
</ScrollArea.Autosize>
</>
)}
<Divider />
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
leftSection={<ChevronLeft size={16} />}
onClick={closePicker}
>
Back to queue
</Button>
<Button
color="edr-green"
leftSection={<PackageCheck size={16} />}
loading={fulfill.isPending}
disabled={picked.size !== need}
onClick={handleFulfill}
>
Transfer {need} wagon{need === 1 ? "" : "s"}
</Button>
</Group>
</Stack>
)}
</Tabs.Panel>
<Tabs.Panel value="history">
<HistoryPanel opened={opened} />
</Tabs.Panel>
</Tabs>
</Modal>
);
};
export default WagonTransferRequestsModal;

View File

@@ -40,7 +40,12 @@ const clampInt = (v: number | string, max: number): number => {
return Math.min(Math.floor(n), max);
};
/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `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,
@@ -49,10 +54,11 @@ const QuantityField = ({
}: {
value: number;
onChange: (n: number) => void;
max: number;
max?: number;
disabled?: boolean;
}) => {
const set = (v: number | string) => onChange(clampInt(v, max));
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}>
@@ -73,19 +79,25 @@ const QuantityField = ({
value={value}
onChange={set}
min={0}
max={Math.max(max, 1)}
max={Math.max(max ?? Math.max(value, 10), 1)}
disabled={off}
label={(v) => `${v}`}
color="edr-green"
/>
</Group>
<Group gap={6}>
<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>
{/* 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
@@ -242,9 +254,10 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
}
}, [opened]);
// Keep quantities within bounds as counts shift after each action. Transfers
// may only ask for AVAILABLE wagons, so the request cap is availableCount.
useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
// 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]);
@@ -453,11 +466,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
{availableCount} available
</Badge>
</Group>
<QuantityField
value={transferQty}
onChange={setTransferQty}
max={availableCount}
/>
{/* 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"

View File

@@ -130,6 +130,10 @@ export const FREIGHT_PERMS = {
transferRequest: "edr_freight_app:wagons:transfer_request",
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
/** Open the transfer desk. `wagons:view` is accepted as a fallback. */
transferView: "edr_freight_app:wagons:transfer_view",
transferCancel: "edr_freight_app:wagons:transfer_cancel",
transferCloseShort: "edr_freight_app:wagons:transfer_close_short",
},
trains: {
view: "edr_freight_app:trains:view",

View File

@@ -9,7 +9,7 @@ import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions"
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Inbox, Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { Link, Navigate, useLocation } from "react-router-dom";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
@@ -19,7 +19,6 @@ import FleetToolbar from "@/components/fleet/FleetToolbar";
import { matchesDayRange } from "@/hooks/useListControls";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -67,7 +66,6 @@ const FleetResourcePage = () => {
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
const [transferRequestsOpen, setTransferRequestsOpen] = useState(false);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
@@ -90,6 +88,11 @@ const FleetResourcePage = () => {
if (trainNumber && trainNumber !== "ALL") {
(filters as { trainNumber?: string }).trainNumber = trainNumber;
}
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
const wagonTypeId = listFilterValues.wagonTypeId;
if (wagonTypeId && wagonTypeId !== "ALL") {
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
}
if (slug !== "locomotives" && search.trim()) {
filters.search = search.trim();
}
@@ -449,12 +452,15 @@ const FleetResourcePage = () => {
</Button>
) : null}
{canTransfer ? (
// The desk is its own page now (list + fulfil + history with
// pagination); this is just the way in from the fleet list.
<Button
component={Link}
to="/dashboard/wagon-transfers"
variant="light"
color="grape"
leftSection={<Inbox size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setTransferRequestsOpen(true)}
>
Transfer Requests
</Button>
@@ -713,13 +719,6 @@ const FleetResourcePage = () => {
/>
) : null}
{slug === "wagons" ? (
<WagonTransferRequestsModal
opened={transferRequestsOpen}
onClose={() => setTransferRequestsOpen(false)}
/>
) : null}
{slug === "wagons" ? (
<WagonMovementHistoryModal
opened={Boolean(historyTarget)}

View File

@@ -305,6 +305,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
allLabel: "All statuses",
options: WAGON_STATUS_OPTIONS,
},
{
key: "wagonTypeId",
label: "Wagon type",
allLabel: "All types",
dynamicOptions: "wagonTypes",
},
{
key: "currentYardId",
label: "Current Yard",

View File

@@ -0,0 +1,195 @@
import { Freight } from "@edr/types";
import {
Alert,
Button,
Checkbox,
Group,
Loader,
Modal,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, PackageCheck } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import type { WagonTransferRequest } from "@/services/wagon.service";
import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui";
export interface TransferFulfillModalProps {
request: WagonTransferRequest | null;
onClose: () => void;
onDone?: () => void;
}
/**
* Move wagons against an open request. Any number from one up to whatever is
* still owed — a yard that can only spare 20 of 50 sends 20 now and the request
* stays open for the rest, so the picker caps at the OUTSTANDING count, not the
* originally requested one.
*/
export function TransferFulfillModal({
request,
onClose,
onDone,
}: TransferFulfillModalProps) {
const [picked, setPicked] = useState<Set<string>>(new Set());
const outstanding = request ? outstandingOn(request) : 0;
const { data: wagons = [], isLoading } = useQuery({
...api.wagons.list.queryOptions({
input: {
filters: request
? {
currentYardId: request.fromYardId,
wagonTypeId: request.wagonTypeId,
status: Freight.WagonStatus.Available,
}
: {},
},
}),
enabled: Boolean(request),
});
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
const canTake = useMemo(
() => Math.min(outstanding, wagons.length),
[outstanding, wagons.length],
);
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
// Never let staff pick more than is still owed — the API rejects it too.
else if (next.size >= outstanding) return prev;
else next.add(id);
return next;
});
const takeAllAvailable = () =>
setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id)));
const close = () => {
setPicked(new Set());
onClose();
};
const submit = async () => {
if (!request || picked.size === 0) return;
try {
const moved = picked.size;
await fulfill.mutateAsync({ id: request.id, wagonIds: [...picked] });
toast.success(
moved >= outstanding
? `Request complete — ${moved} wagon(s) transferred`
: `${moved} wagon(s) transferred · ${outstanding - moved} still owed`,
);
close();
onDone?.();
} catch {
// The http interceptor surfaces the server's reason.
}
};
return (
<Modal
opened={Boolean(request)}
onClose={close}
size="lg"
radius="md"
title={
request ? (
<Group gap={8} wrap="nowrap">
<Text fw={700}>{yardLabel(request.fromYard)}</Text>
<ArrowRight size={15} />
<Text fw={700}>{yardLabel(request.toYard)}</Text>
<Text c="dimmed" size="sm">
{wagonTypeLabel(request.wagonType)}
</Text>
</Group>
) : null
}
>
{!request ? null : (
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<Text size="sm">
<Text span fw={700}>
{outstanding}
</Text>{" "}
wagon(s) still owed ·{" "}
<Text span fw={700}>
{wagons.length}
</Text>{" "}
available in {yardLabel(request.fromYard)}
</Text>
<Button
variant="light"
size="xs"
radius="md"
disabled={canTake === 0}
onClick={takeAllAvailable}
>
Select {canTake}
</Button>
</Group>
{wagons.length < outstanding ? (
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
This yard can only cover {wagons.length} of the {outstanding}{" "}
outstanding. Send what is here the request stays open for the
rest, or close it short so the requester can ask another yard.
</Alert>
) : null}
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : wagons.length === 0 ? (
<Text c="dimmed" size="sm" py="md">
No available wagons of this type in the source yard right now.
</Text>
) : (
<ScrollArea.Autosize mah={320}>
<Stack gap={4}>
{wagons.map((w) => (
<Checkbox
key={w.id}
checked={picked.has(w.id)}
onChange={() => toggle(w.id)}
label={w.wagonNumber}
/>
))}
</Stack>
</ScrollArea.Autosize>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={close}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={15} />}
loading={fulfill.isPending}
disabled={picked.size === 0}
onClick={() => void submit()}
>
Transfer {picked.size || ""}
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
export default TransferFulfillModal;

View File

@@ -0,0 +1,276 @@
import {
Alert,
Button,
Group,
Modal,
NumberInput,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, Send, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import type { WagonTransferRequest } from "@/services/wagon.service";
import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui";
/** Yard + wagon-type option lists, shared by both modals. */
function useTransferOptions(enabled: boolean) {
const { data: yards = [] } = useQuery({
...api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
enabled,
});
const { data: wagonTypes = [] } = useQuery({
...api.wagonTypes.list.queryOptions(),
enabled,
});
return {
yardOptions: yards.map((y) => ({
value: y.id,
label: y.label ?? y.code ?? y.id,
})),
typeOptions: wagonTypes.map((t) => ({
value: t.id,
label: [t.code, t.name].filter(Boolean).join(" · "),
})),
};
}
export interface TransferRequestFormModalProps {
opened: boolean;
onClose: () => void;
/**
* Carry-over from a request that could not be met in full: the destination,
* type, outstanding count and reason are pre-filled and the user only picks
* WHICH other yard to ask. Undefined for a plain new request.
*/
prefillFrom?: WagonTransferRequest | null;
onCreated?: () => void;
}
/**
* File a wagon-transfer request. The count is deliberately NOT capped by what
* the source yard holds today — OCC fulfils in instalments, so asking for 50
* where 20 sit is a normal request.
*/
export function TransferRequestFormModal({
opened,
onClose,
prefillFrom,
onCreated,
}: TransferRequestFormModalProps) {
const { yardOptions, typeOptions } = useTransferOptions(opened);
const [fromYardId, setFromYardId] = useState<string | null>(null);
const [toYardId, setToYardId] = useState<string | null>(null);
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
const [quantity, setQuantity] = useState<number | string>(1);
const [reason, setReason] = useState("");
// Re-seed on every open so a carry-over never leaks into the next request.
useEffect(() => {
if (!opened) return;
setFromYardId(null); // always chosen fresh — that is the point of a re-ask
setToYardId(prefillFrom?.toYardId ?? null);
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
setReason(prefillFrom?.reason ?? "");
}, [opened, prefillFrom]);
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
const valid =
Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) &&
!sameYard &&
Number(quantity) >= 1;
const submit = async () => {
if (!valid) return;
try {
await create.mutateAsync({
fromYardId: fromYardId!,
toYardId: toYardId!,
wagonTypeId: wagonTypeId!,
quantity: Number(quantity),
reason: reason.trim(),
});
toast.success("Transfer request filed");
onClose();
onCreated?.();
} catch {
// Server reason is surfaced by the http interceptor.
}
};
return (
<Modal
opened={opened}
onClose={onClose}
radius="md"
size="md"
title={prefillFrom ? "Request the rest from another yard" : "New transfer request"}
>
<Stack gap="sm">
{prefillFrom ? (
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
{yardLabel(prefillFrom.fromYard)} supplied{" "}
{prefillFrom.fulfilledQuantity} of {prefillFrom.quantity}. Pick
another yard to cover the remaining {outstandingOn(prefillFrom)}.
</Alert>
) : null}
<Select
label="Source yard"
description={prefillFrom ? "Which yard should supply the rest" : undefined}
placeholder="Where the wagons come from"
data={yardOptions}
value={fromYardId}
onChange={setFromYardId}
searchable
required
error={sameYard ? "Source and destination must differ" : undefined}
/>
<Select
label="Destination yard"
placeholder="Where they are needed"
data={yardOptions}
value={toYardId}
onChange={setToYardId}
searchable
required
/>
<Select
label="Wagon type"
placeholder="Type of wagon"
data={typeOptions}
value={wagonTypeId}
onChange={setWagonTypeId}
searchable
required
/>
<NumberInput
label="How many"
description="Can exceed what the yard holds today — OCC delivers in instalments"
min={1}
value={quantity}
onChange={setQuantity}
required
/>
<Textarea
label="Reason"
placeholder="Why the wagons are needed"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
loading={create.isPending}
disabled={!valid}
onClick={() => void submit()}
>
File request
</Button>
</Group>
</Stack>
</Modal>
);
}
export interface TransferCloseShortModalProps {
request: WagonTransferRequest | null;
onClose: () => void;
onClosed?: (request: WagonTransferRequest) => void;
}
/**
* End a request the source yard cannot finish. What already moved stays moved;
* the requester is notified of the shortfall so they can raise it elsewhere.
*/
export function TransferCloseShortModal({
request,
onClose,
onClosed,
}: TransferCloseShortModalProps) {
const [note, setNote] = useState("");
const closeShort = useMutation(
api.wagonTransferRequests.closeShort.mutationOptions(),
);
useEffect(() => {
if (request) setNote("");
}, [request]);
const submit = async () => {
if (!request) return;
try {
await closeShort.mutateAsync({ id: request.id, note: note.trim() || undefined });
toast.success("Request closed — the requester has been notified");
onClose();
onClosed?.(request);
} catch {
// Server reason surfaced by the http interceptor.
}
};
const outstanding = request ? outstandingOn(request) : 0;
return (
<Modal
opened={Boolean(request)}
onClose={onClose}
radius="md"
title="Close this request short"
>
{!request ? null : (
<Stack gap="sm">
<Text size="sm">
{yardLabel(request.fromYard)} {yardLabel(request.toYard)} ·{" "}
{wagonTypeLabel(request.wagonType)}
</Text>
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
{request.fulfilledQuantity} of {request.quantity} wagon(s) have been
supplied. Closing leaves {outstanding} undelivered the requester is
told to ask another yard.
</Alert>
<Textarea
label="Why can't the yard supply the rest?"
description="Included in the requester's notification"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Keep it open
</Button>
<Button
color="orange"
radius="md"
leftSection={<XCircle size={15} />}
loading={closeShort.isPending}
onClick={() => void submit()}
>
Close short
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,625 @@
import { Freight } from "@edr/types";
import {
Box,
Button,
Card,
Group,
Loader,
Select,
Stack,
Switch,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
History,
Inbox,
PackageCheck,
Plus,
RefreshCw,
Search,
Send,
Truck,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useMutation } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
TransferRequestListFilter,
WagonTransferRequest,
} from "@/services/wagon.service";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import TransferFulfillModal from "./TransferFulfillModal";
import {
TransferCloseShortModal,
TransferRequestFormModal,
} from "./TransferRequestModals";
import {
TransferProgress,
TransferStatusBadge,
fmtDateTime,
isOpenRequest,
outstandingOn,
wagonTypeLabel,
yardLabel,
} from "./wagon-transfer-ui";
const S = Freight.WagonTransferRequestStatus;
/** "Open" is the working set: nothing delivered yet OR part-delivered. */
const OPEN_STATUSES = `${S.Pending},${S.PartiallyFulfilled}`;
const STATUS_FILTER_OPTIONS = [
{ value: OPEN_STATUSES, label: "Open (awaiting wagons)" },
{ value: S.Pending, label: "Not started" },
{ value: S.PartiallyFulfilled, label: "Partly delivered" },
{ value: S.Fulfilled, label: "Complete" },
{ value: S.ClosedShort, label: "Closed short" },
{ value: S.Cancelled, label: "Cancelled" },
];
/**
* The wagon-transfer desk.
*
* A request is a count, not a wagon list: someone asks for 50 gondolas from
* Dire Dawa, and OCC sends whatever that yard can spare, whenever it can. The
* table is built around that — every row shows delivered-vs-asked, and a
* request only leaves the queue when it is fully supplied or explicitly closed
* short (which tells the requester to try another yard).
*/
export default function WagonTransfersPage() {
const { user } = useAuth();
const canRequest = hasPermission(user, FREIGHT_PERMS.wagons.transferRequest);
const canFulfil = hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill);
const canCloseShort =
canFulfil || hasPermission(user, FREIGHT_PERMS.wagons.transferCloseShort);
const canCancel =
canRequest || hasPermission(user, FREIGHT_PERMS.wagons.transferCancel);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [status, setStatus] = useState<string | null>(OPEN_STATUSES);
const [fromYardId, setFromYardId] = useState<string | null>(null);
const [toYardId, setToYardId] = useState<string | null>(null);
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [formOpen, setFormOpen] = useState(false);
const [carryOver, setCarryOver] = useState<WagonTransferRequest | null>(null);
const [fulfilling, setFulfilling] = useState<WagonTransferRequest | null>(null);
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
null,
);
const filter: TransferRequestListFilter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(status ? { status } : {}),
...(fromYardId ? { fromYardId } : {}),
...(toYardId ? { toYardId } : {}),
...(wagonTypeId ? { wagonTypeId } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
}),
[
pagination.pageIndex,
pagination.pageSize,
status,
fromYardId,
toYardId,
wagonTypeId,
debouncedSearch,
],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.wagonTransferRequests.list.queryOptions({ input: { filter } }),
);
const rows = data?.items ?? [];
const meta = data?.meta;
const { data: yards = [] } = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
);
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
const yardOptions = yards.map((y) => ({
value: y.id,
label: y.label ?? y.code ?? y.id,
}));
const typeOptions = wagonTypes.map((t) => ({
value: t.id,
label: [t.code, t.name].filter(Boolean).join(" · "),
}));
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const clearFilters = () => {
setStatus(OPEN_STATUSES);
setFromYardId(null);
setToYardId(null);
setWagonTypeId(null);
setSearch("");
resetPage();
};
const columns: ColumnDef<WagonTransferRequest>[] = [
{
id: "route",
header: () => <span>Route</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{yardLabel(r.fromYard)}
</Text>
<ArrowRight size={13} className="shrink-0 opacity-60" />
<Text size="sm" fw={600}>
{yardLabel(r.toYard)}
</Text>
</Group>
);
},
},
{
id: "type",
header: () => <span>Wagon type</span>,
cell: ({ row }) => (
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
),
},
{
id: "progress",
header: () => <span>Delivered</span>,
cell: ({ row }) => <TransferProgress request={row.original} />,
},
{
id: "reason",
header: () => <span>Reason</span>,
cell: ({ row }) => (
<Text size="sm" c="dimmed" lineClamp={2} maw={260}>
{row.original.reason || "—"}
</Text>
),
},
{
id: "filed",
header: () => <span>Filed</span>,
cell: ({ row }) => (
<Text size="xs" c="dimmed">
{fmtDateTime(row.original.createdAt)}
</Text>
),
},
{
id: "status",
header: () => <span>Status</span>,
cell: ({ row }) => <TransferStatusBadge status={row.original.status} />,
},
{
id: "actions",
header: () => <span />,
cell: ({ row }) => {
const r = row.original;
const open = isOpenRequest(r);
const short =
r.status === S.ClosedShort && outstandingOn(r) > 0;
return (
<Group gap={6} justify="flex-end" wrap="nowrap">
{open && canFulfil ? (
<Button
size="xs"
radius="md"
color="edr-green"
leftSection={<Truck size={13} />}
onClick={() => setFulfilling(r)}
>
Transfer
</Button>
) : null}
{open && r.fulfilledQuantity > 0 && canCloseShort ? (
<Button
size="xs"
radius="md"
variant="light"
color="orange"
leftSection={<XCircle size={13} />}
onClick={() => setClosingShort(r)}
>
Close short
</Button>
) : null}
{short && canRequest ? (
<Button
size="xs"
radius="md"
variant="light"
color="grape"
leftSection={<Send size={13} />}
onClick={() => {
setCarryOver(r);
setFormOpen(true);
}}
>
Ask another yard
</Button>
) : null}
{r.status === S.Pending && canCancel ? (
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({ id: r.id });
toast.success("Request withdrawn");
} catch {
// interceptor surfaces the reason
}
}}
>
Withdraw
</Button>
) : null}
</Group>
);
},
},
];
const openCount = rows.filter(isOpenRequest).length;
const outstandingWagons = rows.reduce(
(sum: number, r: WagonTransferRequest) =>
sum + (isOpenRequest(r) ? outstandingOn(r) : 0),
0,
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Wagon transfers"
subtitle="Requests for wagons to move between yards — delivered in instalments until the full count is met"
breadcrumbs={[
{ label: "Wagons", href: "/dashboard/wagons" },
{ label: "Transfers" },
]}
action={
<Group gap="sm">
<Button
variant="default"
radius="md"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
{canRequest ? (
<Button
color="edr-green"
radius="md"
leftSection={<Plus size={15} />}
onClick={() => {
setCarryOver(null);
setFormOpen(true);
}}
>
New request
</Button>
) : null}
</Group>
}
/>
<KpiStrip
items={[
{
label: "Open on this page",
value: openCount,
icon: Inbox,
},
{
label: "Wagons still owed",
value: outstandingWagons,
icon: Truck,
},
{
label: "Requests matched",
value: meta?.total ?? 0,
icon: PackageCheck,
},
]}
/>
<Tabs defaultValue="requests" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="requests" leftSection={<Inbox size={15} />}>
Requests
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={15} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="requests" pt="md">
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search the reason…"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetPage();
}}
w={240}
radius="md"
/>
<Select
placeholder="Status"
data={STATUS_FILTER_OPTIONS}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
w={200}
radius="md"
/>
<Select
placeholder="From yard"
data={yardOptions}
value={fromYardId}
onChange={(v) => {
setFromYardId(v);
resetPage();
}}
searchable
clearable
w={180}
radius="md"
/>
<Select
placeholder="To yard"
data={yardOptions}
value={toYardId}
onChange={(v) => {
setToYardId(v);
resetPage();
}}
searchable
clearable
w={180}
radius="md"
/>
<Select
placeholder="Wagon type"
data={typeOptions}
value={wagonTypeId}
onChange={(v) => {
setWagonTypeId(v);
resetPage();
}}
searchable
clearable
w={180}
radius="md"
/>
<Button variant="subtle" radius="md" onClick={clearFilters}>
Clear
</Button>
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: meta?.totalPages ?? 1,
totalCount: meta?.total ?? 0,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount: meta?.totalPages ?? 1,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</Tabs.Panel>
<Tabs.Panel value="history" pt="md">
<TransferHistoryPanel />
</Tabs.Panel>
</Tabs>
</Stack>
<TransferRequestFormModal
opened={formOpen}
prefillFrom={carryOver}
onClose={() => {
setFormOpen(false);
setCarryOver(null);
}}
onCreated={() => void refetch()}
/>
<TransferFulfillModal
request={fulfilling}
onClose={() => setFulfilling(null)}
onDone={() => void refetch()}
/>
<TransferCloseShortModal
request={closingShort}
onClose={() => setClosingShort(null)}
onClosed={(r) => {
void refetch();
// Straight into the re-ask: the shortfall is the whole reason this
// request was closed, so offer the other-yard form immediately.
if (canRequest) {
setCarryOver(r);
setFormOpen(true);
}
}}
/>
</PageContainer>
);
}
/**
* Who moved what. A staffer sees their own activity; holders of
* `transfer_history_all` can widen it to every staffer (the backend enforces
* the scope regardless of the toggle).
*/
function TransferHistoryPanel() {
const { user } = useAuth();
const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
const [allStaff, setAllStaff] = useState(false);
const [page, setPage] = useState(1);
const scopeAll = canSeeAll && allStaff;
const mine = useQuery({
...api.wagonTransferRequests.history.queryOptions({
input: { page, pageSize: 20 },
}),
enabled: !scopeAll,
});
const all = useQuery({
...api.wagonTransferRequests.historyAll.queryOptions({
input: { page, pageSize: 20 },
}),
enabled: scopeAll,
});
const source = scopeAll ? all : mine;
const requests = source.data?.requests ?? [];
const movements = source.data?.movements ?? [];
const meta = source.data?.meta;
return (
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text fw={600}>Transfer history</Text>
{canSeeAll ? (
<Switch
label="All staff"
checked={allStaff}
onChange={(e) => {
setAllStaff(e.currentTarget.checked);
setPage(1);
}}
/>
) : null}
</Group>
{source.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : (
<Group align="flex-start" grow gap="lg" wrap="wrap">
<Stack gap={6} miw={280}>
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
Requests ({meta?.requestsTotal ?? 0})
</Text>
{requests.length === 0 ? (
<Text size="sm" c="dimmed">
Nothing yet.
</Text>
) : (
requests.map((r) => (
<Group key={r.id} gap={8} wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{yardLabel(r.fromYard)} {yardLabel(r.toYard)} ·{" "}
{r.fulfilledQuantity}/{r.quantity}
</Text>
<TransferStatusBadge status={r.status} />
</Group>
))
)}
</Stack>
<Stack gap={6} miw={280}>
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
Wagons moved ({meta?.movementsTotal ?? 0})
</Text>
{movements.length === 0 ? (
<Text size="sm" c="dimmed">
Nothing yet.
</Text>
) : (
movements.map((m) => (
<Group key={m.id} gap={8} wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{m.wagon?.wagonNumber ?? "Wagon"} · {yardLabel(m.fromYard)} {" "}
{yardLabel(m.toYard)}
</Text>
<Text size="xs" c="dimmed">
{fmtDateTime(m.occurredAt)}
</Text>
</Group>
))
)}
</Stack>
</Group>
)}
<Group justify="center" gap="sm">
<Button
variant="default"
size="xs"
radius="md"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Text size="sm" c="dimmed">
Page {meta?.page ?? page} of {meta?.totalPages ?? 1}
</Text>
<Button
variant="default"
size="xs"
radius="md"
disabled={page >= (meta?.totalPages ?? 1)}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</Group>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,93 @@
import { Freight } from "@edr/types";
import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
import type { WagonTransferRequest } from "@/services/wagon.service";
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
y?.label || y?.code || "—";
export const wagonTypeLabel = (t?: { code?: string; name?: string } | null) =>
t ? [t.code, t.name].filter(Boolean).join(" · ") : "—";
/** Wagons still owed on a request (0 once it is complete or closed). */
export const outstandingOn = (r: WagonTransferRequest): number =>
Math.max(0, r.quantity - (r.fulfilledQuantity ?? 0));
/** A request OCC can still move wagons against. */
export const isOpenRequest = (r: WagonTransferRequest): boolean =>
r.status === Freight.WagonTransferRequestStatus.Pending ||
r.status === Freight.WagonTransferRequestStatus.PartiallyFulfilled;
export const STATUS_META: Record<string, { label: string; color: string }> = {
PENDING: { label: "Awaiting wagons", color: "gray" },
PARTIALLY_FULFILLED: { label: "Partly delivered", color: "yellow" },
FULFILLED: { label: "Complete", color: "teal" },
CLOSED_SHORT: { label: "Closed short", color: "orange" },
CANCELLED: { label: "Cancelled", color: "red" },
};
export function TransferStatusBadge({ status }: { status: string }) {
const meta = STATUS_META[status] ?? { label: status, color: "gray" };
return (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
);
}
/**
* Delivered-vs-asked bar. The number is what staff actually need — the bar just
* makes "nearly there" vs "barely started" readable at a glance across a page
* of requests.
*/
export function TransferProgress({ request }: { request: WagonTransferRequest }) {
const delivered = request.fulfilledQuantity ?? 0;
const percent = request.quantity > 0 ? (delivered / request.quantity) * 100 : 0;
const outstanding = outstandingOn(request);
const complete = delivered >= request.quantity;
const closedShort =
request.status === Freight.WagonTransferRequestStatus.ClosedShort;
return (
<Tooltip
withArrow
label={
complete
? "Fully supplied"
: closedShort
? `Closed ${outstanding} wagon(s) short`
: `${outstanding} wagon(s) still to come`
}
>
<Box miw={110}>
<Group gap={6} justify="space-between" wrap="nowrap" mb={4}>
<Text size="sm" fw={700} style={{ fontVariantNumeric: "tabular-nums" }}>
{delivered} / {request.quantity}
</Text>
{!complete && !closedShort ? (
<Text size="xs" c="dimmed">
{outstanding} left
</Text>
) : null}
</Group>
<Progress
value={percent}
size="sm"
radius="xl"
color={complete ? "teal" : closedShort ? "orange" : "yellow"}
/>
</Box>
</Tooltip>
);
}
export const fmtDateTime = (iso?: string | null) =>
iso
? new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
: "—";

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from "@edr/types";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { BookingDetail } from "@/types/booking";
@@ -208,6 +210,7 @@ import {
type CreateTransferRequestPayload,
type BulkFulfillResult,
type TransferHistory,
type TransferRequestListFilter,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -1719,14 +1722,14 @@ export const api = {
wagonTransferRequests: {
list: endpoint<
{ status?: WagonTransferRequest["status"] },
WagonTransferRequest[]
{ filter?: TransferRequestListFilter },
PaginatedResponse<WagonTransferRequest>
>(
"wagonTransferRequests",
"list",
({ status }) =>
wagonTransferRequestService.list(status).then((r) => r.data),
({ status }) => ["wagonTransferRequests", "list", status ?? "ALL"],
({ filter }) =>
wagonTransferRequestService.list(filter).then((r) => r.data),
({ filter }) => ["wagonTransferRequests", "list", filter ?? {}],
),
getById: endpoint<{ id: string }, WagonTransferRequest>(
@@ -1774,19 +1777,47 @@ export const api = {
() => [["wagonTransferRequests"]],
),
history: endpoint<void, TransferHistory>(
closeShort: endpoint<{ id: string; note?: string }, WagonTransferRequest>(
"wagonTransferRequests",
"history",
() => wagonTransferRequestService.myHistory().then((r) => r.data),
() => ["wagonTransferRequests", "history", "mine"],
"closeShort",
({ id, note }) =>
wagonTransferRequestService.closeShort(id, note).then((r) => r.data),
undefined,
() => [["wagonTransferRequests"]],
),
historyAll: endpoint<{ userId?: string }, TransferHistory>(
history: endpoint<{ page?: number; pageSize?: number }, TransferHistory>(
"wagonTransferRequests",
"history",
({ page, pageSize }) =>
wagonTransferRequestService.myHistory(page, pageSize).then((r) => r.data),
({ page, pageSize }) => [
"wagonTransferRequests",
"history",
"mine",
page ?? 1,
pageSize ?? 20,
],
),
historyAll: endpoint<
{ userId?: string; page?: number; pageSize?: number },
TransferHistory
>(
"wagonTransferRequests",
"historyAll",
({ userId }) =>
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
({ userId, page, pageSize }) =>
wagonTransferRequestService
.allHistory(userId, page, pageSize)
.then((r) => r.data),
({ userId, page, pageSize }) => [
"wagonTransferRequests",
"history",
"all",
userId ?? "",
page ?? 1,
pageSize ?? 20,
],
),
},

View File

@@ -1,4 +1,4 @@
import type { Freight } from "@edr/types";
import type { Freight, PaginatedResponse } from "@edr/types";
import { api as apiClient } from "../auth/http";
@@ -109,10 +109,15 @@ export interface WagonTransferRequest {
toYardId: string;
wagonTypeId: string;
quantity: number;
/** How many have actually moved so far — OCC delivers in instalments. */
fulfilledQuantity: number;
status: Freight.WagonTransferRequestStatus;
requestedByUserId: string | null;
fulfilledByUserId: string | null;
/** When the LAST instalment ran, not necessarily the full count. */
fulfilledAt: string | null;
closedShortAt?: string | null;
closedShortByUserId?: string | null;
/** Why the wagons are needed — required for new requests, shown on the queue. */
reason?: string | null;
note: string | null;
@@ -132,7 +137,7 @@ export interface CreateTransferRequestPayload {
note?: string;
}
/** Bulk accept-and-execute result: what ran, what stayed PENDING and why. */
/** Bulk accept-and-execute result: what ran, what stayed open and why. */
export interface BulkFulfillResult {
fulfilled: WagonTransferRequest[];
skipped: Array<{ id: string; reason: string }>;
@@ -142,20 +147,52 @@ export interface BulkFulfillResult {
export interface TransferHistory {
requests: WagonTransferRequest[];
movements: WagonMovementRecord[];
meta: {
page: number;
pageSize: number;
requestsTotal: number;
movementsTotal: number;
totalPages: number;
};
}
/** Desk list filters — `status` may be a comma-separated set ("open" tab). */
export interface TransferRequestListFilter {
status?: string;
fromYardId?: string;
toYardId?: string;
wagonTypeId?: string;
search?: string;
page?: number;
pageSize?: number;
}
const listQuery = (filter: TransferRequestListFilter = {}): string => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter)) {
if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value));
}
}
const qs = params.toString();
return qs ? `?${qs}` : '';
};
export const wagonTransferRequestService = {
list: (status?: Freight.WagonTransferRequestStatus) =>
apiClient.get<WagonTransferRequest[]>(
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
list: (filter: TransferRequestListFilter = {}) =>
apiClient.get<PaginatedResponse<WagonTransferRequest>>(
`/wagon-transfer-requests${listQuery(filter)}`,
),
/** The caller's own history (both roles: requests they filed and fulfilled). */
myHistory: () =>
apiClient.get<TransferHistory>('/wagon-transfer-requests/history'),
/** Admin: any/all staff's history (optional userId filter). */
allHistory: (userId?: string) =>
myHistory: (page = 1, pageSize = 20) =>
apiClient.get<TransferHistory>(
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
`/wagon-transfer-requests/history?page=${page}&pageSize=${pageSize}`,
),
/** Admin: any/all staff's history (optional userId filter). */
allHistory: (userId?: string, page = 1, pageSize = 20) =>
apiClient.get<TransferHistory>(
`/wagon-transfer-requests/history/all?page=${page}&pageSize=${pageSize}` +
`${userId ? `&userId=${userId}` : ''}`,
),
getById: (id: string) =>
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
@@ -176,4 +213,10 @@ export const wagonTransferRequestService = {
apiClient.post<WagonTransferRequest>(
`/wagon-transfer-requests/${id}/cancel`,
),
/** OCC: end the request short — the source yard has no more to give. */
closeShort: (id: string, note?: string) =>
apiClient.post<WagonTransferRequest>(
`/wagon-transfer-requests/${id}/close-short`,
{ note },
),
};

View File

@@ -616,6 +616,12 @@ export interface TrainScheduleDetail {
wagons: Array<{
id: string;
sequenceNo: number;
/**
* Place in the drawn consist, 1..n — the built train's real coupling
* order (reversed for a reverseWagonOrder schedule). Label wagons with
* this, not sequenceNo: a slot's stored sequenceNo is not its position.
*/
position?: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;