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

@@ -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,
})
: "—";