Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
Marshal 861b8dc38a feat: Refactor general contract handling and introduce drawdown orders
- Updated ContractRouteLineView to clarify that routes carry no quantity and are pure origin-destination lanes.
- Modified GeneralContractService to reflect changes in route handling, removing quantity-related logic.
- Adjusted BookingsService to persist contracted routes without quantities, aligning with the new contract structure.
- Revised CreateBookingDto and CreateContractRouteDto to remove quantity fields, emphasizing shared pool usage.
- Added ContractOrdersPanel component to display drawdown orders and their associated pool.
- Implemented useContractOrders and useContractPool hooks for fetching order and pool data.
- Created booking-orders.service.ts to manage API interactions for drawdown orders and pool data.
- Updated BookingRequestDetailPage and PlaceOrderDialog to accommodate new order handling logic.
2026-06-25 07:06:48 +00:00

348 lines
11 KiB
TypeScript

import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FileSignature,
Layers,
LayoutGrid,
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,
BookingDocumentsCard,
ClearanceReviewSection,
ContractOrdersPanel,
type BookingFileView,
} 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 { downloadBookingFile } from "@/services/files.service";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
// in the booking's Documents list.
const SIGNATURE_FILE_CODES = new Set([
"signature",
"signature_customer",
"signature_staff",
"contract",
]);
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const {
data: booking,
isLoading,
isError,
refetch,
isFetching,
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
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 showContractButton = [
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
].includes(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";
const showTabs = showClearanceTab || isGeneralContract;
const requestedTab = searchParams.get("tab");
const activeTab =
requestedTab === "clearance" && showClearanceTab
? "clearance"
: requestedTab === "orders" && isGeneralContract
? "orders"
: "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 */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{showTabs ? (
<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.List>
<Tabs.Panel value="overview">
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
</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>
) : (
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
)}
</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} />
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
/>
<BookingActionsToolbar
booking={booking}
mutations={mutations}
/>
{showContractButton && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View & sign contract
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
function OverviewPanel({
booking,
row,
onDownload,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onDownload: (file: BookingFileView) => void;
}) {
return (
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={onDownload}
/>
</Stack>
);
}