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:
Marshal
2026-08-18 12:50:35 +00:00
parent c723b660e2
commit 22a3fb98ee
25 changed files with 2121 additions and 34 deletions

View File

@@ -9,6 +9,7 @@ import {
FolderOpen,
Layers,
LayoutGrid,
Link2,
Milestone,
MoreHorizontal,
Package,
@@ -79,14 +80,50 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
// other half of the shared wagon. Everything below — KPIs, stepper, the
// overview/orders/documents/trucks sub-tabs, the action toolbar — then reads
// from the selected booking, so each half gets its own complete detail page
// under a top-level tab. The URL id stays put so Back still works.
const selectedId = searchParams.get("booking") || id;
const {
data: booking,
isLoading,
isError,
refetch,
isFetching,
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
} = useBookingDetail(selectedId);
const mutations = useBookingMutations(selectedId ?? "");
// The pair is discovered from whichever half is on screen: each booking
// carries a reference to the other.
const routeBookingId = id ?? "";
const partnerId = booking?.consolidationPartnerId ?? null;
const isPaired = Boolean(partnerId);
const viewingPartner = selectedId !== routeBookingId;
// Tab identities: the booking named by the URL is always the first tab, the
// other half the second — regardless of which one is currently displayed.
const firstTabId = routeBookingId;
const secondTabId = viewingPartner ? selectedId : partnerId;
// Only for the tab label (reference + customer) — the displayed half is
// loaded above. Skipped entirely when the booking is not part of a pair.
const { data: otherBooking } = useBookingDetail(
secondTabId && secondTabId !== selectedId ? secondTabId : undefined,
);
const firstTabBooking = viewingPartner ? otherBooking : booking;
const secondTabBooking = viewingPartner ? booking : otherBooking;
const selectBooking = (bookingId: string) => {
const next = new URLSearchParams(searchParams);
if (bookingId === routeBookingId) next.delete("booking");
else next.set("booking", bookingId);
// Switching booking resets the sub-tab: the other half has its own content
// and may not even have the tab that was open (e.g. Orders).
next.delete("tab");
setSearchParams(next, { replace: true });
};
if (isLoading) {
return (
@@ -349,6 +386,48 @@ export default function BookingRequestDetailPage() {
/>
<Stack gap="lg">
{/* Consolidated pair: one tab per booking, switching the ENTIRE page
below. The overview/orders/documents/trucks tabs further down are
sub-tabs of whichever booking is selected here. */}
{isPaired && secondTabId ? (
<Tabs
value={selectedId ?? undefined}
onChange={(value) => value && selectBooking(value)}
variant="pills"
radius="md"
>
<Tabs.List>
<Tabs.Tab value={firstTabId} leftSection={<Link2 size={15} />}>
<Stack gap={0} align="flex-start">
<Text fz={13} fw={700}>
{firstTabBooking?.reference ?? "Booking"}
</Text>
<Text fz={11} c="dimmed">
{firstTabBooking?.company?.name ?? "—"}
</Text>
</Stack>
</Tabs.Tab>
<Tabs.Tab value={secondTabId} leftSection={<Link2 size={15} />}>
<Stack gap={0} align="flex-start">
<Text fz={13} fw={700}>
{secondTabBooking?.reference ?? "Partner booking"}
</Text>
<Text fz={11} c="dimmed">
{secondTabBooking?.company?.name ?? "—"}
</Text>
</Stack>
</Tabs.Tab>
</Tabs.List>
</Tabs>
) : null}
{isPaired ? (
<Text size="xs" c="dimmed">
These two bookings share one wagon. Accepting or cancelling applies
to both; each is invoiced and paid separately.
</Text>
) : null}
<KpiStrip items={kpis} />
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (

View File

@@ -15,6 +15,7 @@ import {
CheckCircle2,
Clock,
LayoutList,
Link2,
Package,
Plus,
RefreshCw,
@@ -200,10 +201,28 @@ export default function BookingRequestsPage() {
// Search is applied server-side (via the `search` filter param) — no
// client-side filtering here.
const rows = useMemo(
() => (data?.items ?? []).map(toBookingListRow),
[data?.items],
);
const rows = useMemo(() => {
const mapped = (data?.items ?? []).map(toBookingListRow);
// Consolidated pairs share one wagon and are decided together, so they show
// as ONE row. Keep the half that appears first in the current sort and hang
// the other on it as `pairedWith`; the row renders both bookings' details
// and opens the detail page, where each half gets its own tab.
const byId = new Map(mapped.map((row) => [row.id, row]));
const absorbed = new Set<string>();
const merged: BookingListRow[] = [];
for (const row of mapped) {
if (absorbed.has(row.id)) continue;
const partnerId = row.consolidationPartnerId;
const partner = partnerId ? byId.get(partnerId) : undefined;
if (partner && !absorbed.has(partner.id)) {
absorbed.add(partner.id);
merged.push({ ...row, pairedWith: partner });
continue;
}
merged.push(row);
}
return merged;
}, [data?.items]);
const total = data?.total ?? 0;
const hasSearch = controls.searchText.trim().length > 0;
@@ -321,6 +340,22 @@ export default function BookingRequestsPage() {
</Badge>
) : null}
</p>
{/* Shared wagon: the second booking rides in the same row, so the
operator sees both customers before opening the pair. */}
{b.pairedWith ? (
<div className="mt-1.5 border-l-2 border-muted pl-2">
<div className="flex items-center gap-1.5">
<Link2 className="size-3 shrink-0 opacity-70" />
<p className="truncate text-xs font-medium text-foreground">
{b.pairedWith.reference}
</p>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{b.pairedWith.customerLabel}
</p>
</div>
) : null}
</div>
</div>
);