Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
2026-07-07 21:42:59 +00:00

322 lines
10 KiB
TypeScript

import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FolderOpen,
Layers,
LayoutGrid,
Milestone,
Package,
ShieldCheck,
} from "lucide-react";
import {
Container,
Stack,
Grid,
Center,
Loader,
Tabs,
Text,
Paper,
Button,
Box,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import {
detailStyles,
BookingRequestHero,
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingContainerUnitsCard,
ClearanceReviewSection,
BookingDocumentsPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { BookingDetail } from "@/types/booking";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
const {
data: booking,
isLoading,
isError,
refetch,
isFetching,
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
if (isLoading) {
return (
<PageContainer>
<Center mih="60vh">
<Stack align="center" gap="md">
<Loader color="gray" />
<Text size="sm" c="dimmed" fw={500}>
Loading booking
</Text>
</Stack>
</Center>
</PageContainer>
);
}
if (isError || !booking) {
return (
<PageContainer>
<Container size="sm" py="xl">
<Paper
radius="md"
withBorder
p="xl"
ta="center"
style={detailStyles.card}
>
<Center>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 16,
background: "var(--mantine-color-gray-1)",
color: "var(--mantine-color-gray-6)",
}}
>
<Package size={32} />
</Box>
</Center>
<Text fw={700} size="lg" mt="lg">
Booking not found
</Text>
<Text size="sm" c="dimmed" mt={4}>
This request may have been removed or the link is invalid.
</Text>
<Button
variant="default"
mt="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/booking-requests")}
>
Back to booking requests
</Button>
</Paper>
</Container>
</PageContainer>
);
}
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
const showApprovalCard =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
// bookings are handled in the Global Logistics clearance queue instead.
const showClearanceTab =
!booking.customsClearingEnabled &&
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
booking.status,
);
// A general contract drives an "Orders" tab: each drawdown order spawns a
// child booking that staff manage (clearance/approval) independently.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
// The Documents tab is always available — every booking can accrue clearance,
// customs-workflow, invoice or notice files — so the tab bar always renders.
const requestedTab = searchParams.get("tab");
const activeTab =
requestedTab === "clearance" && showClearanceTab
? "clearance"
: requestedTab === "orders" && isGeneralContract
? "orders"
: requestedTab === "documents"
? "documents"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
else next.delete("tab");
setSearchParams(next, { replace: true });
};
return (
<PageContainer>
<Breadcrumbs
items={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
/>
<Stack gap="lg">
<BookingRequestHero
booking={booking}
customerLabel={row.customerLabel}
onBack={() => navigate("/dashboard/booking-requests")}
onRefresh={() => refetch()}
isFetching={isFetching}
/>
<BookingWorkflowStepper
status={booking.status}
title={statusMeta.title}
description={statusMeta.description}
titleColor={statusMeta.color}
/>
{booking.status === "PENDING_CONSOLIDATION" && (
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
<Grid gap="lg">
{/* LEFT — primary content, split into tabs to keep each view focused.
The Documents tab is always present, so the tab bar always renders. */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Tabs
value={activeTab}
onChange={setActiveTab}
variant="pills"
color="edr-blue"
keepMounted={false}
>
<Tabs.List mb="lg">
<Tabs.Tab
value="overview"
leftSection={<LayoutGrid size={16} />}
>
Overview
</Tabs.Tab>
{isGeneralContract && (
<Tabs.Tab value="orders" leftSection={<Layers size={16} />}>
Orders
</Tabs.Tab>
)}
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Customer clearance
</Tabs.Tab>
)}
<Tabs.Tab
value="documents"
leftSection={<FolderOpen size={16} />}
>
Documents
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel booking={booking} row={row} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
<ContractOrdersPanel
contractBookingId={booking.id}
isContainer={booking.freightType === "CONTAINER"}
/>
</Tabs.Panel>
)}
{showClearanceTab && (
<Tabs.Panel value="clearance">
<ClearanceReviewSection
bookingId={booking.id}
onChanged={() => refetch()}
/>
</Tabs.Panel>
)}
<Tabs.Panel value="documents">
<BookingDocumentsPanel bookingId={booking.id} />
</Tabs.Panel>
</Tabs>
</Grid.Col>
{/* RIGHT — sticky action / summary rail */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
/>
</Box>
<BookingActionsToolbar
booking={booking}
mutations={mutations}
/>
{booking.customsClearingEnabled && (
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View document clearance
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
}) {
return (
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
</Stack>
);
}