mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 17:05:02 +00:00
feat: Implement consolidated booking functionality
- Added support for viewing and managing consolidated bookings in BookingRequestDetailPage. - Enhanced BookingRequestsPage to display paired bookings in a single row. - Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair. - Updated contracts service to include methods for manual consolidation of odd-20ft bookings. - Created new components for selecting and editing consolidation partners. - Added tests for paired decision logic and manual consolidation scenarios. - Updated UI to reflect changes in booking handling and provide user feedback for odd container counts.
This commit is contained in:
@@ -38,6 +38,7 @@ export function BookingActionsMenu({
|
||||
reference: row.reference,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
consolidationPartnerId: row.consolidationPartnerId,
|
||||
};
|
||||
|
||||
const flow = useBookingActionDialog(row.id, context);
|
||||
@@ -92,7 +93,13 @@ export function BookingActionsMenu({
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||
<ActionDialog
|
||||
flow={flow}
|
||||
pendingAction={pendingAction}
|
||||
onSuppressRowClick={onSuppressRowClick}
|
||||
consolidationPartnerId={row.consolidationPartnerId}
|
||||
consolidationPartnerReference={row.consolidationPartnerReference}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +156,13 @@ export function BookingActionsMenu({
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||
<ActionDialog
|
||||
flow={flow}
|
||||
pendingAction={pendingAction}
|
||||
onSuppressRowClick={onSuppressRowClick}
|
||||
consolidationPartnerId={row.consolidationPartnerId}
|
||||
consolidationPartnerReference={row.consolidationPartnerReference}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -158,10 +171,14 @@ function ActionDialog({
|
||||
flow,
|
||||
pendingAction,
|
||||
onSuppressRowClick,
|
||||
consolidationPartnerId,
|
||||
consolidationPartnerReference,
|
||||
}: {
|
||||
flow: ReturnType<typeof useBookingActionDialog>;
|
||||
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
|
||||
onSuppressRowClick?: () => void;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartnerReference?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<BookingConfirmDialog
|
||||
@@ -182,6 +199,17 @@ function ActionDialog({
|
||||
}}
|
||||
isPending={flow.mutations.isPending || flow.detailLoading}
|
||||
confirmDisabled={flow.confirmDisabled}
|
||||
// Only the four pairable decisions land on both halves; the rest stay
|
||||
// per booking, so the warning must not appear for them.
|
||||
pairedWithReference={
|
||||
consolidationPartnerId &&
|
||||
pendingAction &&
|
||||
["accept", "cancel", "operationAccept", "requestChanges"].includes(
|
||||
pendingAction.id,
|
||||
)
|
||||
? (consolidationPartnerReference ?? "its wagon partner")
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Modal,
|
||||
Group,
|
||||
Stack,
|
||||
@@ -37,6 +39,12 @@ interface BookingConfirmDialogProps {
|
||||
isPending: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
extra?: ReactNode;
|
||||
/**
|
||||
* Reference of the booking sharing this one's wagon. When set, the dialog
|
||||
* warns that the decision lands on BOTH bookings — staff must not think they
|
||||
* are acting on one.
|
||||
*/
|
||||
pairedWithReference?: string | null;
|
||||
}
|
||||
|
||||
export function BookingConfirmDialog({
|
||||
@@ -52,6 +60,7 @@ export function BookingConfirmDialog({
|
||||
isPending,
|
||||
confirmDisabled = false,
|
||||
extra,
|
||||
pairedWithReference = null,
|
||||
}: BookingConfirmDialogProps) {
|
||||
if (!action || !action.confirmTitle) return null;
|
||||
|
||||
@@ -125,6 +134,21 @@ export function BookingConfirmDialog({
|
||||
{action.confirmDescription}
|
||||
</Text>
|
||||
)}
|
||||
{pairedWithReference && (
|
||||
<Alert
|
||||
color="blue"
|
||||
variant="light"
|
||||
radius="md"
|
||||
mt="sm"
|
||||
icon={<Link2 size={16} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
This applies to <strong>{pairedWithReference}</strong> as well —
|
||||
the two bookings share a wagon and are decided together. If either
|
||||
fails, neither changes.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Body */}
|
||||
|
||||
@@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean {
|
||||
return Number.isInteger(days) && days >= 1 && days <= 365;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decisions that must be applied to BOTH halves of a consolidated pair. The two
|
||||
* bookings share one wagon: accepting one alone would put half a wagon into the
|
||||
* approval chain, and cancelling one alone would strand the other on a wagon it
|
||||
* can no longer fill.
|
||||
*/
|
||||
const PAIRED_DECISIONS = {
|
||||
accept: "accept",
|
||||
cancel: "cancel",
|
||||
operationAccept: "operationAccept",
|
||||
requestChanges: "requestChanges",
|
||||
} as const;
|
||||
|
||||
export function useBookingActionDialog(
|
||||
bookingId: string,
|
||||
context: BookingActionContext,
|
||||
@@ -52,6 +65,30 @@ export function useBookingActionDialog(
|
||||
|
||||
const onSuccess = () => closeDialog();
|
||||
|
||||
// A booking on a shared wagon routes the four pairable decisions through the
|
||||
// paired endpoint, which applies them to both halves all-or-nothing. Every
|
||||
// other action stays per booking.
|
||||
const pairedDecision =
|
||||
PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS];
|
||||
if (context.consolidationPartnerId && pairedDecision) {
|
||||
if (pairedDecision === "accept") {
|
||||
const days = Number(inputValue.trim());
|
||||
if (!Number.isInteger(days) || days < 1 || days > 365) return;
|
||||
mutations.pairedDecision.mutate(
|
||||
{ decision: "accept", validityDays: days },
|
||||
{ onSuccess },
|
||||
);
|
||||
return;
|
||||
}
|
||||
mutations.pairedDecision.mutate(
|
||||
pairedDecision === "cancel"
|
||||
? { decision: "cancel", reason: inputValue.trim() }
|
||||
: { decision: pairedDecision, note: inputValue.trim() },
|
||||
{ onSuccess },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (pendingAction.id) {
|
||||
case "accept": {
|
||||
const days = Number(inputValue.trim());
|
||||
|
||||
Reference in New Issue
Block a user