mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 22:55:02 +00:00
660 lines
23 KiB
TypeScript
660 lines
23 KiB
TypeScript
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import toast from "react-hot-toast";
|
|
import {
|
|
ArrowLeft,
|
|
Container as ContainerIcon,
|
|
FileSignature,
|
|
FileText,
|
|
Flame,
|
|
FolderOpen,
|
|
Layers,
|
|
LayoutGrid,
|
|
Link2,
|
|
Milestone,
|
|
MoreHorizontal,
|
|
Package,
|
|
Receipt,
|
|
RefreshCw,
|
|
Ship,
|
|
Truck,
|
|
Wallet,
|
|
Weight,
|
|
} from "lucide-react";
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Center,
|
|
Container,
|
|
Grid,
|
|
Group,
|
|
Loader,
|
|
Menu,
|
|
Paper,
|
|
SegmentedControl,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
} from "@mantine/core";
|
|
|
|
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
|
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
|
import type { KpiItem } from "@/components/page";
|
|
import { EntityLink } from "@/components/detail";
|
|
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
|
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
|
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
|
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
|
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
|
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
|
|
import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard";
|
|
import {
|
|
detailStyles,
|
|
BookingRouteServiceCard,
|
|
BookingMileServicesCard,
|
|
BookingCargoCard,
|
|
BookingCompanyCard,
|
|
BookingContractCard,
|
|
BookingContainerUnitsCard,
|
|
BookingSchedulingWindowCard,
|
|
BookingDocumentsPanel,
|
|
BookingTrucksPanel,
|
|
ContractOrdersPanel,
|
|
} from "@/components/bookings/detail";
|
|
import { WarehouseInfoCard } from "@/components/warehouses";
|
|
import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsTab";
|
|
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
|
import { formatDateTime, formatMoney } from "@/lib/format";
|
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
|
import type { BookingDetail } from "@/types/booking";
|
|
import {
|
|
useBookingDetail,
|
|
useBookingMutations,
|
|
} from "@/hooks/bookings/useBookings";
|
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
|
import { bookingsService } from "@/services/bookings.service";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
|
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
|
|
|
|
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 { view, viewer } = useFileViewer();
|
|
const { user } = useAuth();
|
|
const canSeeAdditionalCharges = hasFreightPermission(
|
|
user,
|
|
FREIGHT_PERMS.additionalCharges.view,
|
|
);
|
|
|
|
// 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(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 (
|
|
<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 statusMeta = getStatusMeta(booking.status);
|
|
// Clearance review + finalize now lives solely on the Operations "Clearance
|
|
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
|
|
// no clearance tab is embedded here anymore.
|
|
// 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 === "orders" && isGeneralContract
|
|
? "orders"
|
|
: requestedTab === "documents"
|
|
? "documents"
|
|
: requestedTab === "trucks"
|
|
? "trucks"
|
|
: requestedTab === "additional-charges"
|
|
? "additional-charges"
|
|
: "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 });
|
|
};
|
|
|
|
const company = booking.company;
|
|
const shippingLine = booking.shippingLineCompany ?? null;
|
|
const customerName = toBookingListRow(booking).customerLabel;
|
|
// What is being shipped, in words: bulk → the commodity (Wheat, Steel…);
|
|
// containers → the shipper's own description when given.
|
|
const cargoLabel =
|
|
booking.freightType === "BULK"
|
|
? (booking.cargoType?.label ?? booking.cargoType?.name ?? null)
|
|
: (booking.cargoFreeText?.trim() || null);
|
|
|
|
const amount = Number(booking.totalAmount);
|
|
const containers = booking.bookingContainers ?? [];
|
|
const containerCount = containers.reduce(
|
|
(sum, c) => sum + Number(c.quantity ?? 0),
|
|
0,
|
|
);
|
|
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
|
|
|
const kpis: KpiItem[] = [
|
|
{
|
|
label: "Total value",
|
|
value: formatMoney(amount, booking.paymentCurrency, 2),
|
|
hint: booking.paymentStatus,
|
|
icon: Wallet,
|
|
color: "edr-green",
|
|
},
|
|
{
|
|
label: "Cargo weight",
|
|
value: `${weight} T`,
|
|
hint: itemCount != null ? `${itemCount} items` : "VGM total",
|
|
icon: Weight,
|
|
color: "blue",
|
|
},
|
|
{
|
|
label: "Containers",
|
|
value: containerCount || "—",
|
|
hint: `${containers.length} line${containers.length === 1 ? "" : "s"}`,
|
|
icon: ContainerIcon,
|
|
color: "teal",
|
|
},
|
|
{
|
|
label: "Priority score",
|
|
value: booking.priorityScore ?? 0,
|
|
hint: booking.tradeDirection,
|
|
icon: Flame,
|
|
color: "orange",
|
|
},
|
|
];
|
|
|
|
const hasSignableContract = booking.isGovernment && booking.contractSummary;
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
breadcrumbs={[
|
|
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
|
{ label: booking.reference },
|
|
]}
|
|
backTo="/dashboard/booking-requests"
|
|
title={booking.reference}
|
|
meta={
|
|
<Group gap={6} wrap="wrap">
|
|
<BookingStatusBadge status={booking.status} />
|
|
<BookingPriorityBadge score={booking.priorityScore} />
|
|
{booking.schedulingStatus ? (
|
|
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
|
) : null}
|
|
</Group>
|
|
}
|
|
subtitle={
|
|
<Group gap={6} wrap="wrap">
|
|
{shippingLine ? (
|
|
<Group gap={6} wrap="nowrap">
|
|
<Ship size={14} />
|
|
<Text size="sm" fw={600}>
|
|
{shippingLine.name}
|
|
</Text>
|
|
<Badge size="xs" radius="sm" variant="light" color="teal">
|
|
Shipping line
|
|
</Badge>
|
|
</Group>
|
|
) : (
|
|
<EntityLink
|
|
to={company?.id ? `/dashboard/customers/${company.id}` : null}
|
|
label={customerName ?? "—"}
|
|
/>
|
|
)}
|
|
{cargoLabel ? (
|
|
<Text size="sm" c="dimmed">
|
|
· {cargoLabel}
|
|
</Text>
|
|
) : null}
|
|
<Text size="sm" c="dimmed">
|
|
· Scheduled {booking.scheduledDate}
|
|
</Text>
|
|
</Group>
|
|
}
|
|
action={
|
|
<Group gap="sm" wrap="nowrap">
|
|
<ActionIcon
|
|
variant="light"
|
|
color="edr-green"
|
|
size="lg"
|
|
radius="md"
|
|
loading={isFetching}
|
|
aria-label="Refresh"
|
|
onClick={() => refetch()}
|
|
>
|
|
<RefreshCw size={16} />
|
|
</ActionIcon>
|
|
<Menu position="bottom-end" width={260} withinPortal>
|
|
<Menu.Target>
|
|
<ActionIcon
|
|
variant="default"
|
|
size="lg"
|
|
radius="md"
|
|
aria-label="More actions"
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
{hasSignableContract && (
|
|
<Menu.Item
|
|
leftSection={<FileSignature size={15} />}
|
|
onClick={() =>
|
|
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
|
}
|
|
>
|
|
View / sign contract
|
|
</Menu.Item>
|
|
)}
|
|
<Menu.Item
|
|
leftSection={<FileText size={15} />}
|
|
onClick={async () => {
|
|
try {
|
|
const blob =
|
|
await bookingsService.downloadCarriageAcceptanceSheet(
|
|
booking.id,
|
|
);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `carriage-acceptance-${booking.reference}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
// Blob response: the JSON reason is inside the Blob, so
|
|
// the sync path would show only "status code 400".
|
|
toast.error(await extractDownloadErrorMessage(error));
|
|
}
|
|
}}
|
|
>
|
|
Carriage acceptance sheet
|
|
</Menu.Item>
|
|
{booking.customsClearingEnabled && (
|
|
<Menu.Item
|
|
leftSection={<Milestone size={15} />}
|
|
onClick={() =>
|
|
navigate(`/dashboard/bookings/${booking.id}/clearance`)
|
|
}
|
|
>
|
|
View document clearance
|
|
</Menu.Item>
|
|
)}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
}
|
|
/>
|
|
|
|
<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" ? (
|
|
<Text size="xs" c="orange.7">
|
|
Hold expires {formatDateTime(booking.holdExpiresAt)}
|
|
</Text>
|
|
) : null}
|
|
|
|
{booking.nextStep ? (
|
|
<Paper
|
|
radius="lg"
|
|
p={4}
|
|
style={{
|
|
background: "var(--mantine-color-gray-0)",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<NextStepBanner nextStep={booking.nextStep} />
|
|
</Paper>
|
|
) : null}
|
|
|
|
<BookingWorkflowStepper
|
|
status={booking.status}
|
|
title={statusMeta.title}
|
|
description={statusMeta.description}
|
|
titleColor={statusMeta.color}
|
|
/>
|
|
|
|
{booking.status === "PENDING_CONSOLIDATION" && (
|
|
<ConsolidationWaitingBanner bookingId={booking.id} />
|
|
)}
|
|
|
|
{booking.status === "CONSOLIDATION_APPROVAL_PENDING" && (
|
|
<Alert
|
|
color="yellow"
|
|
radius="md"
|
|
icon={<Link2 size={18} />}
|
|
title="Waiting for shared-wagon approval"
|
|
>
|
|
<Text size="sm">
|
|
This booking shares a wagon with another customer's booking.
|
|
Both are held here until the pairing is approved — neither reaches
|
|
Operations before then.
|
|
</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
<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>
|
|
)}
|
|
<Tabs.Tab
|
|
value="documents"
|
|
leftSection={<FolderOpen size={16} />}
|
|
>
|
|
Documents
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
|
|
Trucks
|
|
</Tabs.Tab>
|
|
{canSeeAdditionalCharges && (
|
|
<Tabs.Tab
|
|
value="additional-charges"
|
|
leftSection={<Receipt size={16} />}
|
|
>
|
|
Additional payments
|
|
</Tabs.Tab>
|
|
)}
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="overview">
|
|
<OverviewPanel booking={booking} onRefetch={refetch} />
|
|
</Tabs.Panel>
|
|
{isGeneralContract && (
|
|
<Tabs.Panel value="orders">
|
|
<ContractOrdersPanel
|
|
contractBookingId={booking.id}
|
|
isContainer={booking.freightType === "CONTAINER"}
|
|
/>
|
|
</Tabs.Panel>
|
|
)}
|
|
<Tabs.Panel value="documents">
|
|
<BookingDocumentsPanel bookingId={booking.id} />
|
|
</Tabs.Panel>
|
|
<Tabs.Panel value="trucks">
|
|
<BookingTrucksPanel bookingId={booking.id} />
|
|
</Tabs.Panel>
|
|
{canSeeAdditionalCharges && (
|
|
<Tabs.Panel value="additional-charges">
|
|
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />
|
|
</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} />
|
|
<BookingContractCard booking={booking} />
|
|
<BookingPricingSummary booking={booking} />
|
|
<Box id="warehouse-payments">
|
|
<WarehouseInfoCard
|
|
bookingId={booking.id}
|
|
bookingReference={booking.reference}
|
|
paymentStatus={booking.paymentStatus}
|
|
tradeDirection={booking.tradeDirection}
|
|
/>
|
|
</Box>
|
|
{/* Sits directly above the staff actions: reviewing an operation
|
|
request means approving the booking onto a specific train, so
|
|
that train and its clock must be readable before the approve
|
|
button. */}
|
|
<BookingSchedulingWindowCard booking={booking} />
|
|
{/* Renders itself only when this booking has a shared wagon. */}
|
|
<ConsolidationApprovalCard bookingId={booking.id} />
|
|
<BookingActionsToolbar
|
|
booking={booking}
|
|
mutations={mutations}
|
|
/>
|
|
</Stack>
|
|
</Box>
|
|
</Grid.Col>
|
|
</Grid>
|
|
</Stack>
|
|
{viewer}
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
/** The booking's primary detail cards — route, services, cargo, containers. */
|
|
function OverviewPanel({
|
|
booking,
|
|
onRefetch,
|
|
}: {
|
|
booking: BookingDetail;
|
|
onRefetch: () => void;
|
|
}) {
|
|
const row = toBookingListRow(booking);
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<BookingRouteServiceCard
|
|
booking={booking}
|
|
originLabel={row.originLabel}
|
|
destinationLabel={row.destinationLabel}
|
|
/>
|
|
<BookingMileServicesCard
|
|
booking={booking}
|
|
handoverSection={
|
|
booking.tradeDirection === "EXPORT" ? (
|
|
<Stack gap={6}>
|
|
<Text size="sm" fw={600}>
|
|
How the cargo reaches the train
|
|
</Text>
|
|
<SegmentedControl
|
|
fullWidth
|
|
size="xs"
|
|
value={booking.exportHandoverMode ?? "WAREHOUSE"}
|
|
data={[
|
|
{ value: "WAREHOUSE", label: "Warehouse then train" },
|
|
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
|
|
]}
|
|
onChange={async (value) => {
|
|
try {
|
|
await bookingsService.setExportHandoverMode(
|
|
booking.id,
|
|
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
|
);
|
|
onRefetch();
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: "Could not change the handover mode",
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
<Text size="xs" c="dimmed">
|
|
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
|
|
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
|
|
: "Cargo is received at the warehouse and issued a GRN before loading."}
|
|
</Text>
|
|
</Stack>
|
|
) : null
|
|
}
|
|
/>
|
|
<BookingCargoCard booking={booking} />
|
|
<BookingContainerUnitsCard booking={booking} />
|
|
<WagonCancellationCreditCard bookingId={booking.id} onRebooked={onRefetch} />
|
|
</Stack>
|
|
);
|
|
}
|