mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
- 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.
412 lines
15 KiB
TypeScript
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;
|