Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx
marshalyordanos 5da36eb128 feat: add wagon usage computation and maintenance logging features
- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
2026-08-12 09:36:50 +03:00

596 lines
18 KiB
TypeScript

import { Freight } from "@edr/types";
import {
Box,
Button,
Card,
Group,
Modal,
Select,
Stack,
Tabs,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
History,
Inbox,
PackageCheck,
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 { sanitizeHtml } from "@/shared/lib/sanitize";
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 TransferHistoryPanel from "./TransferHistoryPanel";
import {
TransferCloseShortModal,
TransferRequestFormModal,
} from "./TransferRequestModals";
import {
PreferredWagonChips,
TransferProgress,
TransferStatusBadge,
fmtDateTime,
isOpenRequest,
outstandingOn,
stripHtmlToText,
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 [withdrawing, setWithdrawing] = useState<WagonTransferRequest | null>(
null,
);
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
null,
);
const [viewingReason, setViewingReason] = 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: "wagons",
header: () => <span>Wagons requested</span>,
cell: ({ row }) => <PreferredWagonChips request={row.original} />,
},
{
id: "progress",
header: () => <span>Delivered</span>,
cell: ({ row }) => <TransferProgress request={row.original} />,
},
{
id: "reason",
header: () => <span>Reason</span>,
cell: ({ row }) => {
const text = stripHtmlToText(row.original.reason);
return text ? (
<UnstyledButton
onClick={() => setViewingReason(row.original)}
data-stop-row-click
>
<Text
size="sm"
c="dimmed"
lineClamp={2}
maw={260}
style={{ textAlign: "left", textDecoration: "underline dotted" }}
>
{text}
</Text>
</UnstyledButton>
) : (
<Text size="sm" c="dimmed">
</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"
onClick={() => setWithdrawing(r)}
>
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>
</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);
}
}}
/>
<Modal
opened={Boolean(withdrawing)}
onClose={() => setWithdrawing(null)}
radius="md"
title="Withdraw this request?"
>
{!withdrawing ? null : (
<Stack gap="sm">
<Text size="sm">
{yardLabel(withdrawing.fromYard)} {" "}
{yardLabel(withdrawing.toYard)} ·{" "}
{wagonTypeLabel(withdrawing.wagonType)} ·{" "}
{withdrawing.quantity} wagon(s)
</Text>
<Text size="sm" c="dimmed">
The source yard stops seeing it. Withdrawing can't be undone
raise a new request if you still need the wagons.
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setWithdrawing(null)}
>
Keep it
</Button>
<Button
color="red"
radius="md"
leftSection={<XCircle size={15} />}
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({ id: withdrawing.id });
toast.success("Request withdrawn");
setWithdrawing(null);
} catch {
// interceptor surfaces the reason
}
}}
>
Withdraw
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal
opened={Boolean(viewingReason)}
onClose={() => setViewingReason(null)}
radius="md"
title="Reason"
>
{!viewingReason ? null : (
<Stack gap="sm">
<Text size="sm" fw={600}>
{yardLabel(viewingReason.fromYard)}{" "}
<ArrowRight
size={13}
className="inline-block opacity-60"
/>{" "}
{yardLabel(viewingReason.toYard)} ·{" "}
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
{viewingReason.quantity} wagon(s)
</Text>
{viewingReason.preferredWagons?.length ? (
<div>
<Text size="xs" c="dimmed" mb={4}>
Wagons requested
</Text>
<PreferredWagonChips
request={viewingReason}
limit={viewingReason.preferredWagons.length}
/>
</div>
) : null}
<Box
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(viewingReason.reason ?? ""),
}}
/>
</Stack>
)}
</Modal>
</PageContainer>
);
}