mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -115,6 +115,7 @@ export function LogPassYardWorkModal({
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
|
||||
const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update);
|
||||
const [justLogged, setJustLogged] = useState(false);
|
||||
// When the train was here — defaults to now, past allowed (recorded after the fact).
|
||||
const [passAt, setPassAt] = useState<Date | null>(null);
|
||||
@@ -134,6 +135,10 @@ export function LogPassYardWorkModal({
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
|
||||
// "Leave behind": the cargo is not on the train — unassign frees its wagons
|
||||
// and returns the booking to the pool for a later schedule. Reversible (the
|
||||
// booking can be re-assigned), so no extra confirm step.
|
||||
const leave = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
|
||||
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
|
||||
@@ -196,6 +201,28 @@ export function LogPassYardWorkModal({
|
||||
);
|
||||
};
|
||||
|
||||
const doLeave = (row: YardWorkBookingRow) => {
|
||||
leave.mutate(
|
||||
{ id: scheduleId, bookingId: row.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: `${row.reference ?? "Booking"} left behind`,
|
||||
description:
|
||||
"Removed from this train — wagons freed, booking returned to the pool for a later schedule.",
|
||||
});
|
||||
void yardWorkQuery.refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not leave booking behind",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const hasWork = boarders.length > 0 || arrivals.length > 0;
|
||||
|
||||
return (
|
||||
@@ -355,30 +382,54 @@ export function LogPassYardWorkModal({
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!row.loadedAt ? (
|
||||
<Tooltip
|
||||
label={
|
||||
!canLoad
|
||||
? "You don't have permission to load cargo"
|
||||
: !logged
|
||||
? "Log the pass first — the train must be at this yard"
|
||||
: !row.canLoad
|
||||
? "Booking is not ready to load (payment pending)"
|
||||
: "Confirm cargo loaded onto the train"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!canLoad || !logged || !row.canLoad}
|
||||
loading={
|
||||
load.isPending && load.variables?.bookingId === row.id
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
!canLoad
|
||||
? "You don't have permission to load cargo"
|
||||
: !logged
|
||||
? "Log the pass first — the train must be at this yard"
|
||||
: !row.canLoad
|
||||
? "Booking is not ready to load (payment pending)"
|
||||
: "Confirm cargo loaded onto the train"
|
||||
}
|
||||
onClick={() => doLoad(row)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!canLoad || !logged || !row.canLoad}
|
||||
loading={
|
||||
load.isPending && load.variables?.bookingId === row.id
|
||||
}
|
||||
onClick={() => doLoad(row)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
row.isGovernment
|
||||
? "Government bookings cannot be removed from a train"
|
||||
: !canLeave
|
||||
? "You don't have permission to remove bookings"
|
||||
: "Cargo is not on the train — free its wagons and return the booking to the pool"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={!canLeave || row.isGovernment}
|
||||
loading={
|
||||
leave.isPending && leave.variables?.bookingId === row.id
|
||||
}
|
||||
onClick={() => doLeave(row)}
|
||||
>
|
||||
Leave
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
Reference in New Issue
Block a user