Merge pull request #981 from Tria-plc/freight_feature/usermanagement

update locaomotive and fix wagon transfer issue
This commit is contained in:
marshal
2026-07-27 23:55:12 +03:00
committed by GitHub
23 changed files with 811 additions and 230 deletions

View File

@@ -41,7 +41,12 @@ export default function AvailableWagonsPanel({
const wagonsQuery = useQuery(
api.wagons.list.queryOptions({
input: {
filters: { status: Freight.WagonStatus.Available, currentYardId: yardId },
filters: {
status: Freight.WagonStatus.Available,
currentYardId: yardId,
// Loose wagons only — one already on another train cannot be coupled.
unassigned: true,
},
},
enabled: Boolean(yardId),
}),

View File

@@ -1,7 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
@@ -30,8 +30,13 @@ import {
type FleetFormFieldDef,
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
import {
isFleetServerPaginated,
type FleetListFilters,
type FleetRecord,
} from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { useDebouncedValue } from "@mantine/hooks";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
@@ -53,6 +58,10 @@ const FleetResourcePage = () => {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
// Wagons and locomotives page in the database; the rest still list in full
// and page in the browser (see `pagedHandlers` in fleet.service).
const serverPaged = isFleetServerPaginated(slug);
const [statusFilter, setStatusFilter] = useState("ALL");
// Registration date range. Server-side list filters (status/yard/train) are
// applied by the API; this narrows what comes back, alongside search.
@@ -93,14 +102,42 @@ const FleetResourcePage = () => {
if (wagonTypeId && wagonTypeId !== "ALL") {
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
}
if (slug !== "locomotives" && search.trim()) {
filters.search = search.trim();
// The plain locomotives list has no server-side search — its page window
// does, so the term is only sent on the paginated path.
if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) {
filters.search = debouncedSearch.trim();
}
return filters;
}, [slug, listFilterValues, search]);
}, [slug, listFilterValues, debouncedSearch, serverPaged]);
const { data: allRows = [], isLoading, isError, error } = useQuery(
api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
// On the server-paged path the page window, the search and the registration
// date range are all resolved by the API — nothing is filtered client-side.
const pagedFilters = useMemo(
(): FleetListFilters => ({
...serverListFilters,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(dateFrom ? { createdFrom: dateFrom } : {}),
...(dateTo ? { createdTo: dateTo } : {}),
}),
[serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo],
);
const listQuery = useQuery({
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
enabled: !serverPaged,
});
const pagedQuery = useQuery({
...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
enabled: serverPaged,
placeholderData: keepPreviousData,
});
const activeQuery = serverPaged ? pagedQuery : listQuery;
const { isLoading, isError, error } = activeQuery;
const allRows = useMemo(
() => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
[serverPaged, pagedQuery.data, listQuery.data],
);
const create = useMutation(api.fleet.create.mutationOptions());
const update = useMutation(api.fleet.update.mutationOptions());
@@ -270,6 +307,9 @@ const FleetResourcePage = () => {
const filteredRows = useMemo(() => {
if (!config) return allRows;
// The API already applied every filter and cut the page — re-filtering here
// would drop rows the server deliberately returned.
if (serverPaged) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
@@ -287,13 +327,19 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]);
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const totalCount = serverPaged
? (pagedQuery.data?.meta.total ?? 0)
: filteredRows.length;
const pageCount = serverPaged
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
: Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
if (serverPaged) return filteredRows;
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
}, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
@@ -584,7 +630,7 @@ const FleetResourcePage = () => {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRows.length,
totalCount,
}}
tableOptions={{
manualPagination: true,
@@ -610,7 +656,7 @@ const FleetResourcePage = () => {
emptyMessage={`No ${itemLabel} found`}
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRows.length}
totalCount={totalCount}
onPaginationChange={setPagination}
onEdit={
canUpdate

View File

@@ -1,4 +1,4 @@
import { FormEvent, useMemo, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import {
ArrowRight,
Ban,
@@ -27,7 +27,8 @@ import {
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar";
@@ -155,6 +156,7 @@ function RouteTimeline({ route }: { route: RouteRecord }) {
export default function RoutesPage() {
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [formOpen, setFormOpen] = useState(false);
const [viewing, setViewing] = useState<RouteRecord | null>(null);
const [editing, setEditing] = useState<RouteRecord | null>(null);
@@ -167,7 +169,26 @@ export default function RoutesPage() {
const canUpdate = canFleetAction(user, "routes", "update");
const canDelete = canFleetAction(user, "routes", "delete");
const routesQuery = useQuery(api.routes.list.queryOptions());
const routesQuery = useQuery({
...api.routes.listPaged.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
},
}),
placeholderData: keepPreviousData,
});
// KPI counts stay whole-fleet (they must not move with the search box), so
// they come from two count-only pages rather than the visible one.
const totalCountQuery = useQuery(
api.routes.listPaged.queryOptions({ input: { page: 1, pageSize: 1 } }),
);
const availableCountQuery = useQuery(
api.routes.listPaged.queryOptions({
input: { page: 1, pageSize: 1, status: "AVAILABLE" },
}),
);
const yardsQuery = useQuery(api.routes.yards.queryOptions());
// Segment km are configured in Configuration → Yard Distances and resolved
// by the API on save; this fetch is only to preview them in the form.
@@ -182,35 +203,19 @@ export default function RoutesPage() {
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return routesQuery.data ?? [];
return (routesQuery.data ?? []).filter((route) => {
const searchable = [
formatRouteLabel(route),
route.originYard?.label,
route.originYard?.code,
route.destinationYard?.label,
route.destinationYard?.code,
...(route.milestones ?? []).map(
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return searchable.includes(query);
});
}, [routesQuery.data, search]);
// Narrowing the result set can strand the user on a page that no longer
// exists (search down to 3 rows while on page 5 → empty table).
useEffect(() => {
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
}, [debouncedSearch, setPagination]);
const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize));
const pagedRoutes = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRoutes.slice(start, start + pagination.pageSize);
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
// Filtering, sorting and the page window all happen server-side.
const pagedRoutes = routesQuery.data?.items ?? [];
const matchCount = routesQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, routesQuery.data?.meta.totalPages ?? 1);
const allRoutes = routesQuery.data ?? [];
const availableCount = allRoutes.filter((r) => r.status === "AVAILABLE").length;
const totalRoutes = totalCountQuery.data?.meta.total ?? 0;
const availableCount = availableCountQuery.data?.meta.total ?? 0;
const yardOptions = useMemo(
() =>
@@ -489,11 +494,11 @@ export default function RoutesPage() {
<KpiStrip
loading={routesQuery.isLoading}
items={[
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
{ label: "Total routes", value: totalRoutes, icon: RouteIcon },
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
{
label: "Unavailable",
value: allRoutes.length - availableCount,
value: totalRoutes - availableCount,
icon: Ban,
color: "gray",
},
@@ -522,7 +527,7 @@ export default function RoutesPage() {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRoutes.length,
totalCount: matchCount,
}}
tableOptions={{
manualPagination: true,
@@ -581,7 +586,7 @@ export default function RoutesPage() {
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRoutes.length}
totalCount={matchCount}
itemLabel="routes"
onPaginationChange={setPagination}
/>

View File

@@ -48,6 +48,8 @@ export function TransferFulfillModal({
currentYardId: request.fromYardId,
wagonTypeId: request.wagonTypeId,
status: Freight.WagonStatus.Available,
// A coupled wagon cannot be moved out of its train by a transfer.
unassigned: true,
}
: {},
},

View File

@@ -0,0 +1,342 @@
import {
Badge,
Button,
Card,
Group,
SegmentedControl,
Skeleton,
Stack,
Switch,
Text,
ThemeIcon,
Timeline,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ChevronLeft,
ChevronRight,
History,
Inbox,
Truck,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
WagonMovementRecord,
WagonTransferRequest,
} from "@/services/wagon.service";
import {
STATUS_META,
TransferProgress,
TransferStatusBadge,
wagonTypeLabel,
yardLabel,
} from "./wagon-transfer-ui";
const fmtTime = (iso?: string | null) =>
iso
? new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
: "—";
/** "Today" / "Yesterday" / "Mon 12 Jul 2026" — the header of one timeline block. */
const dayLabel = (iso: string) => {
const d = new Date(iso);
const days = Math.round(
(new Date().setHours(0, 0, 0, 0) - new Date(iso).setHours(0, 0, 0, 0)) /
86_400_000,
);
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
return d.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
});
};
/** Bucket an already-DESC-sorted list into day blocks, order preserved. */
function groupByDay<T>(items: T[], at: (item: T) => string) {
const groups: Array<{ key: string; label: string; items: T[] }> = [];
for (const item of items) {
const iso = at(item);
const key = new Date(iso).toDateString();
const last = groups[groups.length - 1];
if (last?.key === key) last.items.push(item);
else groups.push({ key, label: dayLabel(iso), items: [item] });
}
return groups;
}
const MOVEMENT_KIND_LABEL: Record<string, string> = {
LOADED: "Carried cargo",
EMPTY_REPOSITION: "Repositioned empty",
MANUAL: "Manual move",
};
function EmptyState({ label }: { label: string }) {
return (
<Stack align="center" gap={6} py="xl">
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
<History size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{label}
</Text>
</Stack>
);
}
function RequestItem({ request }: { request: WagonTransferRequest }) {
const meta = STATUS_META[request.status];
return (
<Timeline.Item
bullet={<Inbox size={12} />}
color={meta?.color ?? "gray"}
lineVariant="dotted"
>
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Stack gap={4} style={{ flex: 1, minWidth: 220 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{yardLabel(request.fromYard)}
</Text>
<ArrowRight size={13} className="shrink-0 opacity-60" />
<Text size="sm" fw={600}>
{yardLabel(request.toYard)}
</Text>
<Badge variant="default" radius="sm" size="sm">
{wagonTypeLabel(request.wagonType)}
</Badge>
</Group>
{request.reason ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{request.reason}
</Text>
) : null}
</Stack>
<Group gap="sm" wrap="nowrap">
<TransferProgress request={request} />
<TransferStatusBadge status={request.status} />
<Text size="xs" c="dimmed" w={38} ta="right">
{fmtTime(request.createdAt)}
</Text>
</Group>
</Group>
</Timeline.Item>
);
}
function MovementItem({ movement }: { movement: WagonMovementRecord }) {
return (
<Timeline.Item
bullet={<Truck size={12} />}
color={movement.transferRequestId ? "edr-green" : "gray"}
lineVariant="dotted"
>
<Group justify="space-between" align="center" gap="md" wrap="wrap">
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 220 }}>
<Badge variant="light" color="gray" radius="sm" ff="monospace">
{movement.wagon?.wagonNumber ?? "Wagon"}
</Badge>
<Text size="sm" fw={500}>
{yardLabel(movement.fromYard)}
</Text>
<ArrowRight size={13} className="shrink-0 opacity-60" />
<Text size="sm" fw={500}>
{yardLabel(movement.toYard)}
</Text>
</Group>
<Group gap="sm" wrap="nowrap">
{movement.transferRequestId ? (
<Tooltip label="Delivered against a transfer request" withArrow>
<Badge variant="dot" color="teal" radius="sm" size="sm">
Transfer
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" radius="sm" size="sm">
{MOVEMENT_KIND_LABEL[movement.kind] ?? movement.kind}
</Badge>
)}
<Text size="xs" c="dimmed" w={38} ta="right">
{fmtTime(movement.occurredAt)}
</Text>
</Group>
</Group>
</Timeline.Item>
);
}
/**
* 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).
*/
export default function TransferHistoryPanel() {
const { user } = useAuth();
const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
const [allStaff, setAllStaff] = useState(false);
const [view, setView] = useState<"requests" | "movements">("requests");
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;
const showingRequests = view === "requests";
const total = showingRequests
? (meta?.requestsTotal ?? 0)
: (meta?.movementsTotal ?? 0);
// Each list pages independently on the server; the pager follows the one on screen.
const pageSize = meta?.pageSize ?? 20;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const groups = showingRequests
? groupByDay(requests, (r) => r.createdAt)
: groupByDay(movements, (m) => m.occurredAt);
return (
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Stack gap={2}>
<Text fw={600}>Transfer history</Text>
<Text size="xs" c="dimmed">
{scopeAll
? "Every staffer's requests and wagon moves"
: "Requests you filed or fulfilled, and the wagons you moved"}
</Text>
</Stack>
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
radius="md"
value={view}
onChange={(v) => {
setView(v as "requests" | "movements");
setPage(1);
}}
data={[
{
value: "requests",
label: `Requests ${meta?.requestsTotal ?? 0}`,
},
{
value: "movements",
label: `Wagons moved ${meta?.movementsTotal ?? 0}`,
},
]}
/>
{canSeeAll ? (
<Switch
label="All staff"
checked={allStaff}
onChange={(e) => {
setAllStaff(e.currentTarget.checked);
setPage(1);
}}
/>
) : null}
</Group>
</Group>
{source.isLoading ? (
<Stack gap="sm">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} height={44} radius="md" />
))}
</Stack>
) : groups.length === 0 ? (
<EmptyState
label={
showingRequests
? "No transfer requests recorded yet."
: "No wagon moves recorded yet."
}
/>
) : (
<Stack gap="lg">
{groups.map((group) => (
<Stack key={group.key} gap="xs">
<Group gap="xs" wrap="nowrap">
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
{group.label}
</Text>
<Text size="xs" c="dimmed">
· {group.items.length}
</Text>
</Group>
<Timeline
bulletSize={22}
lineWidth={2}
active={group.items.length}
>
{showingRequests
? (group.items as WagonTransferRequest[]).map((r) => (
<RequestItem key={r.id} request={r} />
))
: (group.items as WagonMovementRecord[]).map((m) => (
<MovementItem key={m.id} movement={m} />
))}
</Timeline>
</Stack>
))}
</Stack>
)}
<Group justify="space-between" gap="sm" wrap="wrap">
<Text size="xs" c="dimmed">
{total} {showingRequests ? "request(s)" : "move(s)"} · page{" "}
{meta?.page ?? page} of {totalPages}
</Text>
<Group gap="xs">
<Button
variant="default"
size="xs"
radius="md"
leftSection={<ChevronLeft size={14} />}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
variant="default"
size="xs"
radius="md"
rightSection={<ChevronRight size={14} />}
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</Group>
</Group>
</Stack>
</Card>
);
}

View File

@@ -4,10 +4,9 @@ import {
Button,
Card,
Group,
Loader,
Modal,
Select,
Stack,
Switch,
Tabs,
Text,
TextInput,
@@ -46,6 +45,7 @@ import {
} from "@edr/ui-common";
import TransferFulfillModal from "./TransferFulfillModal";
import TransferHistoryPanel from "./TransferHistoryPanel";
import {
TransferCloseShortModal,
TransferRequestFormModal,
@@ -103,6 +103,9 @@ export default function WagonTransfersPage() {
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,
);
@@ -269,15 +272,7 @@ export default function WagonTransfersPage() {
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
}
}}
onClick={() => setWithdrawing(r)}
>
Withdraw
</Button>
@@ -494,132 +489,53 @@ export default function WagonTransfersPage() {
}
}}
/>
<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>
</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

@@ -170,6 +170,7 @@ import {
} from "./payments.service";
import {
routesService,
type RouteListFilters,
type RouteRecord,
type SaveRoutePayload,
type YardRef,
@@ -1507,6 +1508,13 @@ export const api = {
(input) => ["routes", input?.status ?? "all"],
),
listPaged: endpoint<RouteListFilters, PaginatedResponse<RouteRecord>>(
"routes",
"listPaged",
(filters) => routesService.getPaged(filters).then((r) => r.data),
(filters) => ["routes", "paged", filters],
),
yards: endpoint<void, YardRef[]>(
"routes",
"yards",
@@ -2082,6 +2090,16 @@ export const api = {
({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), filters ?? {}],
),
listPaged: endpoint<
{ slug: FleetResourceSlug; filters?: FleetListFilters },
PaginatedResponse<FleetRecord>
>(
"fleet",
"listPaged",
({ slug, filters }) => fleetService.listPaged(slug, filters),
({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), "paged", filters ?? {}],
),
create: endpoint<
{ slug: FleetResourceSlug; data: Record<string, unknown> },
unknown

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from "@edr/types";
import { cargoService, type Cargo } from "@/services/cargoService";
import { containerService, type Container } from "@/services/containerService";
import {
@@ -28,6 +30,20 @@ const listHandlers: Record<
drivers: (filters) => driversService.getAll(filters ?? {}).then((r) => r.data),
};
/**
* Server-paginated slugs. Everything else still lists in full and pages in the
* browser — add an entry here once its API grows a `/paged` endpoint.
*/
const pagedHandlers: Partial<
Record<FleetResourceSlug, (filters: FleetListFilters) => Promise<PaginatedResponse<FleetRecord>>>
> = {
locomotives: (filters) => locomotivesService.getPaged(filters).then((r) => r.data),
wagons: (filters) => wagonService.getPaged(filters).then((r) => r.data),
};
export const isFleetServerPaginated = (slug: FleetResourceSlug): boolean =>
slug in pagedHandlers;
const createHandlers: Record<FleetResourceSlug, (data: Record<string, unknown>) => Promise<unknown>> = {
locomotives: (data) => locomotivesService.create(data),
trains: (data) => trainService.create(data),
@@ -63,6 +79,12 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
export const fleetService = {
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
/** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */
listPaged: (slug: FleetResourceSlug, filters: FleetListFilters = {}) => {
const handler = pagedHandlers[slug];
if (!handler) throw new Error(`Fleet resource "${slug}" has no paginated list endpoint`);
return handler(filters);
},
create: (slug: FleetResourceSlug, data: Record<string, unknown>) => createHandlers[slug](data),
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
updateHandlers[slug](id, data),

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from '@edr/types';
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
@@ -19,8 +21,31 @@ export interface LocomotiveListFilters {
excludeCoupled?: boolean;
/** With excludeCoupled: keep THIS train's own coupled locos in the list. */
excludeTrainId?: string;
/** Free-text over code + name — only honoured by `getPaged`. */
search?: string;
/** Registration day range (YYYY-MM-DD), both ends inclusive. */
createdFrom?: string;
createdTo?: string;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
}
const locomotiveListQuery = (filters: LocomotiveListFilters): string => {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.excludeCoupled) params.set('excludeCoupled', 'true');
if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId);
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.createdFrom) params.set('createdFrom', filters.createdFrom);
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();
return qs ? `?${qs}` : '';
};
export interface Locomotive {
id: string;
code: string;
@@ -45,17 +70,15 @@ export type SaveLocomotivePayload = Omit<
>;
export const locomotivesService = {
getAll: (filters: LocomotiveListFilters = {}) => {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.excludeCoupled) params.set('excludeCoupled', 'true');
if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,
);
},
getAll: (filters: LocomotiveListFilters = {}) =>
apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${locomotiveListQuery(filters)}`,
),
/** Same filters as `getAll` plus search, server-paginated ({items, meta}). */
getPaged: (filters: LocomotiveListFilters = {}) =>
apiClient.get<PaginatedResponse<Locomotive>>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}/paged${locomotiveListQuery(filters)}`,
),
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
create: (data: Partial<SaveLocomotivePayload>) =>
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from '@edr/types';
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
@@ -78,9 +80,22 @@ export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }>
{ value: 'STOP_WORKING', label: 'Stop working' },
];
export interface RouteListFilters {
status?: RouteStatus;
search?: string;
page?: number;
pageSize?: number;
}
export const routesService = {
getAll: (params?: { status?: RouteStatus; search?: string }) =>
apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }),
/** Same filters as `getAll`, server-paginated ({items, meta}). */
getPaged: (params: RouteListFilters = {}) =>
apiClient.get<PaginatedResponse<RouteRecord>>(
`${URL_CONSTANTS.ROUTES.BASE}/paged`,
{ params },
),
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
update: (id: string, data: Partial<SaveRoutePayload>) =>

View File

@@ -41,10 +41,35 @@ export interface WagonListFilters {
currentYardId?: string;
wagonTypeId?: string;
trainId?: string;
/** Drop wagons already coupled to a built train — only loose ones can be taken. */
unassigned?: boolean;
/** Run number — matches a wagon whose export OR import run equals it. */
trainNumber?: string;
/** Registration day range (YYYY-MM-DD), both ends inclusive. */
createdFrom?: string;
createdTo?: string;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
}
const wagonListQuery = (filters: WagonListFilters): string => {
const params = new URLSearchParams();
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId);
if (filters.unassigned) params.set('unassigned', 'true');
if (filters.trainNumber) params.set('trainNumber', filters.trainNumber);
if (filters.createdFrom) params.set('createdFrom', filters.createdFrom);
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();
return qs ? `?${qs}` : '';
};
/**
* One row of the wagon_movements ledger: every physical relocation between
* yards — a booking's loaded leg, an empty reposition ride, or a manual staff
@@ -70,17 +95,11 @@ export interface WagonMovementRecord {
}
export const wagonService = {
getAll: (filters: WagonListFilters = {}) => {
const params = new URLSearchParams();
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId);
if (filters.trainNumber) params.set('trainNumber', filters.trainNumber);
const qs = params.toString();
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
},
getAll: (filters: WagonListFilters = {}) =>
apiClient.get<Wagon[]>(`/wagons${wagonListQuery(filters)}`),
/** Same filters as `getAll`, server-paginated ({items, meta}). */
getPaged: (filters: WagonListFilters = {}) =>
apiClient.get<PaginatedResponse<Wagon>>(`/wagons/paged${wagonListQuery(filters)}`),
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getMovements: (id: string) =>
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),