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:
Marshal
2026-08-24 23:49:20 +00:00
parent 2a107e8ba3
commit d5a5085d6d
28 changed files with 1356 additions and 86 deletions

View File

@@ -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} />

View File

@@ -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 {

View File

@@ -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"