mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
344 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|