mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
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.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
@@ -42,6 +44,26 @@ const OUTCOME_OPTIONS = [
|
||||
{ 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`;
|
||||
@@ -49,13 +71,20 @@ 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("");
|
||||
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>(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);
|
||||
@@ -69,13 +98,16 @@ const AuditLogsPage = () => {
|
||||
type: type ?? undefined,
|
||||
method: (method as AuditMethod | null) ?? undefined,
|
||||
isSuccess: outcome === null ? undefined : outcome === "true",
|
||||
// The API filters by record id; the search box is the natural place to
|
||||
// paste one when tracing what happened to a specific contract/booking.
|
||||
resourceId: search.trim() || undefined,
|
||||
// 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, dateFrom, dateTo],
|
||||
[pagination, type, method, outcome, search, action, resourceId, dateFrom, dateTo],
|
||||
);
|
||||
|
||||
const logsQuery = useQuery({
|
||||
@@ -88,12 +120,17 @@ const AuditLogsPage = () => {
|
||||
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 || method || outcome,
|
||||
search || dateFrom || dateTo || type || action || method || outcome,
|
||||
);
|
||||
|
||||
const resetFilters = () => {
|
||||
@@ -101,6 +138,7 @@ const AuditLogsPage = () => {
|
||||
setDateFrom(null);
|
||||
setDateTo(null);
|
||||
setType(null);
|
||||
setAction(null);
|
||||
setMethod(null);
|
||||
setOutcome(null);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
@@ -135,7 +173,7 @@ const AuditLogsPage = () => {
|
||||
<ListControls
|
||||
search={search}
|
||||
onSearchChange={onFilterChange(setSearch)}
|
||||
searchPlaceholder="Filter by record id…"
|
||||
searchPlaceholder="Booking / schedule / train number, staff name, action…"
|
||||
dateFrom={dateFrom}
|
||||
onDateFromChange={onFilterChange(setDateFrom)}
|
||||
dateTo={dateTo}
|
||||
@@ -154,6 +192,16 @@ const AuditLogsPage = () => {
|
||||
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"
|
||||
@@ -193,10 +241,12 @@ const AuditLogsPage = () => {
|
||||
<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>
|
||||
@@ -214,6 +264,11 @@ const AuditLogsPage = () => {
|
||||
<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}
|
||||
@@ -250,6 +305,21 @@ const AuditLogsPage = () => {
|
||||
<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>
|
||||
@@ -277,6 +347,7 @@ const AuditLogsPage = () => {
|
||||
<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} />
|
||||
|
||||
@@ -74,6 +74,23 @@ interface WagonCancellation {
|
||||
};
|
||||
}
|
||||
|
||||
interface RebookPartnerCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}
|
||||
|
||||
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
|
||||
const hasOddFt20 = (r: WagonCancellation): boolean =>
|
||||
Object.entries(r.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => parseInt(size, 10) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
|
||||
2 ===
|
||||
1;
|
||||
|
||||
/** Editable rebook unit — prefilled from the cancelled snapshot. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
@@ -147,10 +164,12 @@ export default function WagonCancellationsPage() {
|
||||
);
|
||||
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
|
||||
const [rebookDate, setRebookDate] = useState<Date | null>(null);
|
||||
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
|
||||
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
const openRebook = (r: WagonCancellation) => {
|
||||
setRebooking(r);
|
||||
setRebookDate(null);
|
||||
setRebookPartnerId(null);
|
||||
setRebookDrafts(
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
@@ -179,9 +198,30 @@ export default function WagonCancellationsPage() {
|
||||
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
|
||||
scheduledDate: toDayString(rebookDate!),
|
||||
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
|
||||
...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick
|
||||
// the odd partner booking riding the chosen day. It ships once that partner pays.
|
||||
const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false;
|
||||
const rebookPartners = useQuery({
|
||||
queryKey: [
|
||||
"wagon-cancellations",
|
||||
rebooking?.id,
|
||||
"rebook-partners",
|
||||
rebookDate ? toDayString(rebookDate) : null,
|
||||
],
|
||||
enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<RebookPartnerCandidate[]>(
|
||||
`/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`,
|
||||
{ params: { scheduledDate: toDayString(rebookDate!) } },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
@@ -301,11 +341,12 @@ export default function WagonCancellationsPage() {
|
||||
const r = row.original;
|
||||
const showVoid = r.status === "FEE_PENDING" && canVoid;
|
||||
// Customs credits are GL's to rebook; non-customs ones the customer
|
||||
// rebooks from the portal.
|
||||
// rebooks from the portal — EXCEPT odd-20ft credits: those must be
|
||||
// re-paired with a partner booking, which only GL can pick.
|
||||
const showRebook =
|
||||
r.status === "CREDIT_AVAILABLE" &&
|
||||
canRebook &&
|
||||
Boolean(r.booking?.customsClearingEnabled) &&
|
||||
(Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
|
||||
Number(r.creditAmount) > 0;
|
||||
if (!showVoid && !showRebook) return null;
|
||||
return (
|
||||
@@ -495,9 +536,43 @@ export default function WagonCancellationsPage() {
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={rebookDate}
|
||||
onChange={(v) => setRebookDate(v ? new Date(v) : null)}
|
||||
onChange={(v) => {
|
||||
setRebookDate(v ? new Date(v) : null);
|
||||
setRebookPartnerId(null);
|
||||
}}
|
||||
radius="md"
|
||||
/>
|
||||
{rebookNeedsPartner && (
|
||||
<Select
|
||||
label="Consolidation partner"
|
||||
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
|
||||
placeholder={
|
||||
!rebookDate
|
||||
? "Pick the day first"
|
||||
: rebookPartners.isLoading
|
||||
? "Loading…"
|
||||
: "Pick the partner booking"
|
||||
}
|
||||
data={(rebookPartners.data ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
|
||||
}))}
|
||||
value={rebookPartnerId}
|
||||
onChange={setRebookPartnerId}
|
||||
disabled={!rebookDate}
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
{rebookNeedsPartner &&
|
||||
rebookDate &&
|
||||
!rebookPartners.isLoading &&
|
||||
(rebookPartners.data ?? []).length === 0 && (
|
||||
<Text size="xs" c="orange">
|
||||
No odd-20ft booking rides that day — pick another day or wait
|
||||
for a partner booking.
|
||||
</Text>
|
||||
)}
|
||||
{rebookDrafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -569,7 +644,9 @@ export default function WagonCancellationsPage() {
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
disabled={!rebookDate}
|
||||
disabled={
|
||||
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
|
||||
}
|
||||
loading={rebook.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
@@ -133,8 +133,14 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Actual departure — staff often dispatch on paper first and record it later,
|
||||
// so the time is picked (defaults to now when the dialog opens).
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
// Loading is manual: dispatch decides the fate of every unloaded origin
|
||||
// boarder — checked = loaded and departs, unchecked = left behind (wagon
|
||||
// freed, booking back to the pool). Default unchecked; government bookings
|
||||
// cannot be removed from a train so they are forced on.
|
||||
const [dispatchLoadedIds, setDispatchLoadedIds] = useState<Set<string>>(new Set());
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchLoadedIds(new Set());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||
@@ -465,6 +471,25 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// per yard from the track page's log-pass flow. Everything below is advisory.
|
||||
const hasDispatchWarnings =
|
||||
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||
// Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual
|
||||
// load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate
|
||||
// (plus government, which is shown but forced-loaded).
|
||||
const originYardId = schedule.originStation?.id;
|
||||
const pendingOriginBoarders = dispatchBookings.filter(
|
||||
(b) =>
|
||||
Boolean(b.originYardId) &&
|
||||
b.originYardId === originYardId &&
|
||||
!b.loadedAt &&
|
||||
(b.loadingStatus ?? "UNLOADED") !== "LOADED" &&
|
||||
(b.isGovernment
|
||||
? b.status === "APPROVED" || b.status === "PAID"
|
||||
: b.status === "PAID" ||
|
||||
// Shipping-line bookings ride from accept on the credit ledger.
|
||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||
);
|
||||
const dispatchLeftCount = pendingOriginBoarders.filter(
|
||||
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
|
||||
).length;
|
||||
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
@@ -516,7 +541,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
try {
|
||||
await dispatch.mutateAsync({
|
||||
id: scheduleId,
|
||||
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
|
||||
payload: {
|
||||
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
||||
loadedBookingIds: pendingOriginBoarders
|
||||
.filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id))
|
||||
.map((b) => b.id),
|
||||
},
|
||||
});
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
@@ -1524,6 +1554,48 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{pendingOriginBoarders.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={700}>
|
||||
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"} —
|
||||
tick what was loaded
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Unticked bookings are left behind: removed from this train, their
|
||||
wagons freed, and the booking returned to the pool for a later
|
||||
schedule. The customer is notified.
|
||||
</Text>
|
||||
<Stack gap={6} mah={220} style={{ overflowY: "auto" }}>
|
||||
{pendingOriginBoarders.map((b) => (
|
||||
<Checkbox
|
||||
key={b.id}
|
||||
size="sm"
|
||||
checked={b.isGovernment || dispatchLoadedIds.has(b.id)}
|
||||
disabled={b.isGovernment}
|
||||
onChange={(e) => {
|
||||
const next = new Set(dispatchLoadedIds);
|
||||
if (e.currentTarget.checked) next.add(b.id);
|
||||
else next.delete(b.id);
|
||||
setDispatchLoadedIds(next);
|
||||
}}
|
||||
label={
|
||||
<Text size="sm" span>
|
||||
{b.reference ?? b.id.slice(0, 8)} — {b.customer ?? "Unknown customer"}
|
||||
{b.isGovernment ? " (government — always rides)" : ""}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{dispatchLeftCount > 0 ? (
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
{dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will
|
||||
be left behind and returned to the booking pool.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
|
||||
Reference in New Issue
Block a user