Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx

344 lines
10 KiB
TypeScript

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,
stripHtmlToText,
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>
{stripHtmlToText(request.reason) ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{stripHtmlToText(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>
);
}