style: inter module integration

This commit is contained in:
Nathnael
2026-08-13 06:54:54 +00:00
parent e0f18f17b3
commit d261d6ea7c
35 changed files with 2104 additions and 2037 deletions

View File

@@ -2,43 +2,56 @@ 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,
Milestone,
MoreHorizontal,
Package,
RefreshCw,
Truck,
Wallet,
Weight,
} from "lucide-react";
import {
Container,
Stack,
Grid,
ActionIcon,
Box,
Button,
Center,
Container,
Grid,
Group,
Loader,
Menu,
Paper,
SegmentedControl,
Stack,
Tabs,
Text,
Paper,
Button,
Box,
SegmentedControl,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
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 {
detailStyles,
BookingRequestHero,
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingContractCard,
BookingContainerUnitsCard,
BookingSchedulingWindowCard,
BookingDocumentsPanel,
@@ -48,6 +61,7 @@ import {
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type { BookingDetail } from "@/types/booking";
import {
useBookingDetail,
@@ -133,7 +147,6 @@ export default function BookingRequestDetailPage() {
);
}
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
// Clearance review + finalize now lives solely on the Operations "Clearance
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
@@ -159,23 +172,176 @@ export default function BookingRequestDetailPage() {
setSearchParams(next, { replace: true });
};
const company = booking.company;
const customerName = toBookingListRow(booking).customerLabel;
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: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 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>
<Breadcrumbs
items={[
<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">
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
<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) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
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">
<BookingRequestHero
booking={booking}
customerLabel={row.customerLabel}
onBack={() => navigate("/dashboard/booking-requests")}
onRefresh={() => refetch()}
isFetching={isFetching}
/>
<KpiStrip items={kpis} />
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</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}
@@ -223,7 +389,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel booking={booking} row={row} />
<OverviewPanel booking={booking} onRefetch={refetch} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
@@ -247,6 +413,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingContractCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard
@@ -265,97 +432,6 @@ export default function BookingRequestDetailPage() {
booking={booking}
mutations={mutations}
/>
{booking.tradeDirection === "EXPORT" && (
<Paper withBorder radius="md" p="sm">
<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",
);
await refetch();
} 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>
</Paper>
)}
{booking.isGovernment && booking.contractSummary && (
<Button
fullWidth
variant="default"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View / sign contract
</Button>
)}
<Button
fullWidth
variant="default"
leftSection={<FileText size={16} />}
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) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Button>
{booking.customsClearingEnabled && (
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View document clearance
</Button>
)}
</Stack>
</Box>
</Grid.Col>
@@ -368,11 +444,13 @@ export default function BookingRequestDetailPage() {
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
onRefetch,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onRefetch: () => void;
}) {
const row = toBookingListRow(booking);
return (
<Stack gap="lg">
<BookingRouteServiceCard
@@ -380,12 +458,49 @@ function OverviewPanel({
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<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} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
</Stack>
);
}

View File

@@ -10,11 +10,9 @@ import {
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
@@ -29,9 +27,13 @@ import {
import type { Freight } from "@edr/types";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import {
SectionCard,
BookingCompanyCard,
BookingContractCard,
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
@@ -171,6 +173,18 @@ export default function DocumentClearanceDetailPage() {
);
}
const direction = booking?.tradeDirection ?? "—";
const origin = booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? "Destination";
const kpis: KpiItem[] = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<PageContainer>
<Stack gap="lg">
@@ -182,25 +196,46 @@ export default function DocumentClearanceDetailPage() {
{ label: reference },
]}
meta={
clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
<Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{direction}
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
{clearance.includesCustoms ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)}
</Group>
}
subtitle={
<Group gap={8} wrap="nowrap">
<Text size="sm" c="dimmed" fw={600}>
{origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed" fw={600}>
{destination}
</Text>
</Group>
}
action={
canCompleteBooking ? (
@@ -233,12 +268,16 @@ export default function DocumentClearanceDetailPage() {
}
/>
<ClearanceHero
booking={booking}
clearance={clearance}
stats={stats}
requestedLines={requestedLines}
/>
<KpiStrip items={kpis} />
{requestedLines ? (
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
) : null}
{isPhasedGeneral ? (
<Paper withBorder radius="md" p="lg">
@@ -273,67 +312,54 @@ export default function DocumentClearanceDetailPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
)}
</Stack>
</SectionCard>
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
}
@@ -347,125 +373,3 @@ export default function DocumentClearanceDetailPage() {
</PageContainer>
);
}
function ClearanceHero({
booking,
clearance,
stats,
requestedLines,
}: {
booking: ReturnType<typeof useBookingDetail>["data"];
clearance: Freight.ClearanceView;
stats: { pct: number; approved: number; total: number };
requestedLines?: Freight.RequestedShipmentLines | null;
}) {
const direction = booking?.tradeDirection ?? "—";
const origin =
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ??
booking?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{booking?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{clearance.includesCustoms ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : null}
</Group>
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
{requestedLines ? (
<>
<Box my="md" h={1} bg="var(--mantine-color-default-border)" />
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
</>
) : null}
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}