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

@@ -88,6 +88,7 @@ import {
import {
ConsolidationPartnerPanel,
emptyPartnerLine,
emptyPartnerUnit,
} from "./gl-booking-form/ConsolidationPartnerPanel";
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
@@ -250,6 +251,17 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// The shipment request is the customer's order: container sizes/quantities
// and the billing currency are the customer's choices and stay read-only —
// GL enters only per-unit details (numbers, seals, VGM, handling). The
// server enforces the same on completion.
const requestContainersLocked = Boolean(
bookingRequest?.requestedLines?.containers?.length,
);
const requestBulkLocked =
bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null;
const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency);
// The expired booking a Rebook is copying from (its cargo seeds the form).
const { data: copyFromBooking } = useQuery({
queryKey: ["rebook-copy-from", copyFromParam],
@@ -328,6 +340,39 @@ export default function GlCreateBookingForm() {
const [partner, setPartner] = useState<ConsolidationCandidate | null>(null);
const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]);
const [partnerCargoDescription, setPartnerCargoDescription] = useState("");
// The partner is its own customer: if a shipment request created it, that
// request locks the partner's quantities and billing currency the same way
// this booking's request locks this side (server enforces both halves).
const { data: partnerContractRequests } = useQuery({
queryKey: ["shipment-requests-for-contract", partner?.contractId],
queryFn: () => contractsService.listBookingRequests(partner!.contractId!),
enabled: Boolean(partner?.contractId),
});
const partnerRequest =
(partner &&
partnerContractRequests?.find(
(r) => r.createdBookingId === partner.id,
)) ||
null;
const partnerLocked = Boolean(partnerRequest?.requestedLines?.containers?.length);
// Seed (and lock) the partner's lines from its request once it loads.
useEffect(() => {
const requested = partnerRequest?.requestedLines?.containers;
if (!partner || !requested?.length) return;
setPartnerLines(
requested.map((c) => ({
containerSize: c.containerSize,
quantity: String(Math.max(1, c.quantity)),
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: Array.from({ length: Math.max(1, c.quantity) }, emptyPartnerUnit),
})),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partner?.id, partnerRequest?.id]);
const seededRef = useRef(false);
const returnSeededRef = useRef(false);
@@ -490,6 +535,14 @@ export default function GlCreateBookingForm() {
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes);
// Currency is the customer's choice on the request — seed it here; the
// selector below is disabled while the request specifies one.
if (
bookingRequest.paymentCurrency === "USD" ||
bookingRequest.paymentCurrency === "ETB"
) {
setPaymentCurrency(bookingRequest.paymentCurrency);
}
}, [bookingRequest, prefilled]);
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
@@ -1114,7 +1167,13 @@ export default function GlCreateBookingForm() {
if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency: effectiveCurrency,
// The partner's customer chose its own currency on its shipment request;
// only a partner without a request falls back to this booking's currency.
paymentCurrency:
partnerRequest?.paymentCurrency === "USD" ||
partnerRequest?.paymentCurrency === "ETB"
? partnerRequest.paymentCurrency
: effectiveCurrency,
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
@@ -1663,6 +1722,12 @@ export default function GlCreateBookingForm() {
label="Quantity *"
min={0}
value={line.quantity}
disabled={requestContainersLocked}
description={
requestContainersLocked
? "Requested by the customer — quantity cannot be changed."
: undefined
}
error={
showErrors
? (lineErrors[lineIdx]?.quantity ??
@@ -1924,6 +1989,7 @@ export default function GlCreateBookingForm() {
showReefer={Boolean(contract.isReefer)}
showErrors={showErrors}
error={partnerError}
lockQuantities={partnerLocked}
/>
</>
) : null}
@@ -1946,6 +2012,12 @@ export default function GlCreateBookingForm() {
placeholder="e.g. 1200"
min={0}
step={0.01}
disabled={requestBulkLocked}
description={
requestBulkLocked
? "Requested by the customer — quantity cannot be changed."
: undefined
}
value={bulk.cargoWeightTons}
error={
showErrors && bulkUom === "PER_TON"
@@ -2147,14 +2219,16 @@ export default function GlCreateBookingForm() {
Billing currency
</Text>
<Text size="xs" c="dimmed" mb={8}>
{isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."}
{requestCurrencyLocked
? "The customer chose the billing currency on the shipment request — it cannot be changed."
: isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."}
</Text>
<CurrencySelector
value={isImport ? paymentCurrency : "ETB"}
onChange={setPaymentCurrency}
disabled={!isImport}
disabled={!isImport || requestCurrencyLocked}
allowUsd={isImport}
error={currencyError}
/>

View File

@@ -86,6 +86,11 @@ interface Props {
/** Surface field errors only after the operator tried to continue. */
showErrors: boolean;
error?: string;
/**
* The partner's shipment request fixed its sizes/quantities — the quantity
* fields render read-only and GL enters only per-unit details.
*/
lockQuantities?: boolean;
}
export function ConsolidationPartnerPanel({
@@ -97,6 +102,7 @@ export function ConsolidationPartnerPanel({
showReefer,
showErrors,
error,
lockQuantities,
}: Props) {
const patchLine = (index: number, patch: Partial<PartnerLineDraft>) => {
onLinesChange(
@@ -149,6 +155,12 @@ export function ConsolidationPartnerPanel({
label="Quantity *"
min={0}
value={line.quantity}
disabled={lockQuantities}
description={
lockQuantities
? "Requested by the partner's customer — quantity cannot be changed."
: undefined
}
onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })}
// Sync off the typed value, not the captured `line` — that snapshot
// still holds the pre-edit quantity and would write it back.