mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +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:
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user