mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
- 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.
596 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|