Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx
Marshal d5a5085d6d feat: enhance booking and audit log functionalities
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component.
- Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel.
- Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains.
- Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages.
- Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking.
- Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings.
- Added a new reference field to the audit logs for better searchability and tracking of actions.
- Created a migration to add the reference column to the audit logs table and established an index for efficient querying.
- Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
2026-08-24 23:49:20 +00:00

412 lines
15 KiB
TypeScript

import { useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Code,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import {
usePagination,
type OnChangeFn,
type PaginationState,
} from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import {
AUDIT_METHODS,
auditLogsService,
type AuditLog,
type AuditMethod,
} from "@/services/auditLogs.service";
/** Method → badge colour. Destructive actions read as the loudest. */
const METHOD_COLORS: Record<AuditMethod, string> = {
POST: "green",
PUT: "blue",
PATCH: "yellow",
DELETE: "red",
};
const OUTCOME_OPTIONS = [
{ value: "true", label: "Succeeded" },
{ value: "false", label: "Failed" },
];
/**
* Entity type → detail page for that record. Drives the row's "Go" button;
* types without a detail page (Wagon, Locomotive, …) simply have no button.
*/
const ENTITY_ROUTES: Record<string, (id: string) => string> = {
Booking: (id) => `/dashboard/booking-requests/${id}`,
Contract: (id) => `/dashboard/contract-requests/${id}`,
Schedule: (id) => `/dashboard/operations/train-scheduling-v2/${id}`,
"Train Schedule": (id) => `/dashboard/operations/train-scheduling-v2/${id}`,
Train: (id) => `/dashboard/trains/${id}`,
"Train Build": (id) => `/dashboard/trains/${id}`,
"EIMS Invoice": (id) => `/dashboard/invoices/${id}`,
Payment: (id) => `/dashboard/invoices/${id}`,
Vehicle: (id) => `/dashboard/vehicles/${id}`,
Company: (id) => `/dashboard/customers/${id}`,
};
const entityRoute = (log: AuditLog): string | null =>
log.resourceId ? (ENTITY_ROUTES[log.type]?.(log.resourceId) ?? null) : null;
/** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */
const startOfDay = (date: string) => `${date}T00:00:00.000Z`;
const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
const AuditLogsPage = () => {
const navigate = useNavigate();
// Entity pages deep-link here as /dashboard/audit-logs?type=Booking&resourceId=<id>
// to show one record's full history with the filters already applied.
const [searchParams] = useSearchParams();
// Server-side filters. Unlike most freight lists (which filter an
// already-fetched array via useListControls), audit_logs is append-only and
// grows without bound, so filtering and paging both happen in the API.
const [search, setSearch] = useState(searchParams.get("q") ?? "");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [type, setType] = useState<string | null>(searchParams.get("type"));
const [resourceId] = useState<string | null>(searchParams.get("resourceId"));
const [action, setAction] = useState<string | null>(null);
const [method, setMethod] = useState<string | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
const [selected, setSelected] = useState<AuditLog | null>(null);
const { pagination, setPagination } = usePagination({ pageIndex: 0, pageSize: 25 });
const query = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
type: type ?? undefined,
method: (method as AuditMethod | null) ?? undefined,
isSuccess: outcome === null ? undefined : outcome === "true",
// Free-text: matches reference (booking/schedule/train number), record
// id, staff name and action title server-side.
q: search.trim() || undefined,
title: action ?? undefined,
// Set only via deep link from an entity page's "History" button.
resourceId: resourceId ?? undefined,
from: dateFrom ? startOfDay(dateFrom) : undefined,
to: dateTo ? endOfDay(dateTo) : undefined,
}),
[pagination, type, method, outcome, search, action, resourceId, dateFrom, dateTo],
);
const logsQuery = useQuery({
queryKey: ["audit-logs", query],
queryFn: () => auditLogsService.list(query),
});
const typesQuery = useQuery({
queryKey: ["audit-logs", "types"],
queryFn: () => auditLogsService.types(),
});
const actionsQuery = useQuery({
queryKey: ["audit-logs", "actions"],
queryFn: () => auditLogsService.actions(),
});
const rows = logsQuery.data?.items ?? [];
const totalCount = logsQuery.data?.meta.total ?? 0;
const pageCount = logsQuery.data?.meta.totalPages ?? 0;
const hasFilters = Boolean(
search || dateFrom || dateTo || type || action || method || outcome,
);
const resetFilters = () => {
setSearch("");
setDateFrom(null);
setDateTo(null);
setType(null);
setAction(null);
setMethod(null);
setOutcome(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
/** Any filter change must return to page 1, or the view can land out of range. */
const onFilterChange = <T,>(setter: (value: T) => void) => (value: T) => {
setter(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
// `OnChangeFn` may hand back either a new value or an updater, so both forms
// are resolved before storing.
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
setPagination((prev) =>
typeof updater === "function" ? updater(prev) : updater,
);
};
// `PageContainer` gives the page the same horizontal inset and vertical
// rhythm as every other dashboard screen; `fluid` lifts the max-width cap
// because the log table is wide.
return (
<PageContainer fluid>
<PageHeader
title="Audit logs"
subtitle="Every state-changing action taken by backoffice staff. Read-only — entries cannot be edited or removed."
/>
<Card withBorder padding="md">
<Stack gap="md">
<ListControls
search={search}
onSearchChange={onFilterChange(setSearch)}
searchPlaceholder="Booking / schedule / train number, staff name, action…"
dateFrom={dateFrom}
onDateFromChange={onFilterChange(setDateFrom)}
dateTo={dateTo}
onDateToChange={onFilterChange(setDateTo)}
dateLabel="Action date"
hasFilters={hasFilters}
onReset={resetFilters}
>
<Select
label="Entity"
placeholder="All entities"
data={typesQuery.data ?? []}
value={type}
onChange={onFilterChange(setType)}
clearable
searchable
w={200}
/>
<Select
label="Action"
placeholder="All actions"
data={actionsQuery.data ?? []}
value={action}
onChange={onFilterChange(setAction)}
clearable
searchable
w={260}
/>
<Select
label="Method"
placeholder="All methods"
data={[...AUDIT_METHODS]}
value={method}
onChange={onFilterChange(setMethod)}
clearable
w={150}
/>
<Select
label="Outcome"
placeholder="Any outcome"
data={OUTCOME_OPTIONS}
value={outcome}
onChange={onFilterChange(setOutcome)}
clearable
w={160}
/>
</ListControls>
{logsQuery.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : logsQuery.isError ? (
<Text c="red" ta="center" py="xl">
Could not load audit logs.
</Text>
) : rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No audit entries match these filters.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Action</Table.Th>
<Table.Th>Entity</Table.Th>
<Table.Th>Reference</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Outcome</Table.Th>
<Table.Th>When</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((log) => (
<Table.Tr
key={log.id}
onClick={() => setSelected(log)}
style={{ cursor: "pointer" }}
>
<Table.Td maw={340}>
<Text size="sm" lineClamp={2}>
{log.title}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light">{log.type}</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" ff="monospace">
{log.reference || "—"}
</Text>
</Table.Td>
<Table.Td>
<Badge color={METHOD_COLORS[log.method]} variant="light">
{log.method}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{log.userName ?? "—"}</Text>
{log.userRole ? (
<Text size="xs" c="dimmed">
{log.userRole}
</Text>
) : null}
</Table.Td>
<Table.Td>
{log.isSuccess ? (
<Badge color="green" variant="light">
Success
</Badge>
) : (
// The status code separates "denied" (403) from
// "broke" (500) — both are simply a failure here.
<Tooltip
label={log.errorMessage ?? "Failed"}
multiline
w={280}
disabled={!log.errorMessage}
>
<Badge color="red" variant="light">
Failed{log.statusCode ? ` · ${log.statusCode}` : ""}
</Badge>
</Tooltip>
)}
</Table.Td>
<Table.Td>
<Text size="sm">{formatTimestamp(log.createdAt)}</Text>
</Table.Td>
<Table.Td>
{entityRoute(log) ? (
<Button
size="compact-xs"
variant="light"
onClick={(event) => {
// The row itself opens the detail modal.
event.stopPropagation();
navigate(entityRoute(log)!);
}}
>
Go
</Button>
) : null}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
itemLabel="entries"
onPaginationChange={handlePaginationChange}
/>
</Stack>
</Card>
<Modal
opened={selected !== null}
onClose={() => setSelected(null)}
title="Audit entry"
size="lg"
>
{selected ? (
<Stack gap="sm">
<DetailRow label="Action" value={selected.title} />
<DetailRow label="Entity" value={selected.type} />
<DetailRow label="Reference" value={selected.reference || null} />
<DetailRow label="Record id" value={selected.resourceId} />
<DetailRow label="Method" value={selected.method} />
<DetailRow label="URL" value={selected.url} />
<DetailRow label="Route" value={selected.routePath} />
<DetailRow
label="Outcome"
value={
selected.isSuccess
? `Success${selected.statusCode ? ` (${selected.statusCode})` : ""}`
: `Failed${selected.statusCode ? ` (${selected.statusCode})` : ""}`
}
/>
{selected.errorMessage ? (
<DetailRow label="Error" value={selected.errorMessage} />
) : null}
<DetailRow label="User" value={selected.userName} />
<DetailRow label="Role" value={selected.userRole} />
<DetailRow label="User id" value={selected.userId} />
<DetailRow label="IP address" value={selected.ipAddress} />
<DetailRow label="Request id" value={selected.requestId} />
<DetailRow
label="Duration"
value={selected.durationMs === null ? null : `${selected.durationMs} ms`}
/>
<DetailRow label="When" value={formatTimestamp(selected.createdAt)} />
<div>
<Text size="sm" fw={600} mb={4}>
Request payload
</Text>
{selected.request ? (
// Secrets are already redacted and uploads reduced to
// descriptors by the API before storage.
<Code block style={{ maxHeight: 320, overflow: "auto" }}>
{JSON.stringify(selected.request, null, 2)}
</Code>
) : (
<Text size="sm" c="dimmed">
No payload recorded.
</Text>
)}
</div>
</Stack>
) : null}
</Modal>
</PageContainer>
);
};
const DetailRow = ({ label, value }: { label: string; value: string | null }) => (
<Group gap="xs" wrap="nowrap" align="flex-start">
<Text size="sm" fw={600} w={120} style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="sm" style={{ wordBreak: "break-all" }}>
{value ?? "—"}
</Text>
</Group>
);
export default AuditLogsPage;