mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
ui improvemnt
This commit is contained in:
@@ -1,11 +1,155 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Container, Stack, Grid } from "@mantine/core";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import {
|
||||
detailStyles,
|
||||
type BookingDetailView,
|
||||
BookingDetailToolbar,
|
||||
BookingDetailHeader,
|
||||
BookingLifecycleStepper,
|
||||
BookingRouteCard,
|
||||
BookingContainersCard,
|
||||
BookingApprovalCard,
|
||||
BookingReviewNotesCard,
|
||||
BookingPaymentCard,
|
||||
BookingFactsCard,
|
||||
BookingDocumentsCard,
|
||||
} from "@/components/bookings/detail";
|
||||
|
||||
const BookingDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Mock data - replace with actual API call
|
||||
const booking: BookingDetailView = {
|
||||
id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f",
|
||||
reference: "BKG-2026-001456",
|
||||
status: "IN_TRANSIT",
|
||||
scheduledDate: "2026-06-15",
|
||||
totalAmount: 15750.5,
|
||||
paymentCurrency: "USD",
|
||||
paymentStatus: "PAID",
|
||||
tradeDirection: "IMPORT",
|
||||
freightType: "CONTAINER",
|
||||
priorityScore: 650,
|
||||
company: {
|
||||
id: "1",
|
||||
companyName: "Global Logistics Inc.",
|
||||
name: "Global Logistics Inc.",
|
||||
},
|
||||
originYard: { id: "1", label: "Port of Shanghai", code: "PVG" },
|
||||
destinationYard: { id: "2", label: "Port of Addis Ababa", code: "AAA" },
|
||||
serviceType: { id: "1", label: "Container Import Service", code: "CIS" },
|
||||
cargoType: { id: "1", label: "Electronics", code: "ELEC" },
|
||||
shippingLine: { id: "1", label: "Maersk Line", code: "MAE" },
|
||||
cargoTotalWeightVgm: 22.5,
|
||||
pnrCode: "PNR-2026-001456",
|
||||
createdAt: "2026-06-05T10:30:00Z",
|
||||
updatedAt: "2026-06-06T14:20:00Z",
|
||||
bookingContainers: [
|
||||
{
|
||||
id: "1",
|
||||
quantity: 2,
|
||||
vgmPerUnitTons: 11.25,
|
||||
containerType: { label: "20FT Standard", sizeFt: 20, isReefer: false },
|
||||
},
|
||||
],
|
||||
approvalSteps: [
|
||||
{
|
||||
id: "1",
|
||||
stepOrder: 1,
|
||||
requiredRole: "LINE_STAFF",
|
||||
status: "APPROVED",
|
||||
actionedAt: "2026-06-05T11:00:00Z",
|
||||
},
|
||||
// {
|
||||
// id: "2",
|
||||
// stepOrder: 2,
|
||||
// requiredRole: "DIRECTOR",
|
||||
// status: "APPROVED",
|
||||
// actionedAt: "2026-06-05T13:30:00Z",
|
||||
// },
|
||||
{
|
||||
id: "3",
|
||||
stepOrder: 3,
|
||||
requiredRole: "CEO",
|
||||
status: "APPROVED",
|
||||
actionedAt: "2026-06-05T15:45:00Z",
|
||||
},
|
||||
],
|
||||
reviewNotes: [
|
||||
{
|
||||
id: "1",
|
||||
note: "Cargo declaration verified against shipping documents.",
|
||||
type: "VERIFICATION",
|
||||
createdAt: "2026-06-05T11:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
note: "VGM documentation received and processed.",
|
||||
type: "COMPLIANCE",
|
||||
createdAt: "2026-06-05T12:00:00Z",
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{ id: "1", name: "Bill_of_Lading.pdf", mimeType: "application/pdf" },
|
||||
{ id: "2", name: "VGM_Certificate.pdf", mimeType: "application/pdf" },
|
||||
{ id: "3", name: "Commercial_Invoice.pdf", mimeType: "application/pdf" },
|
||||
],
|
||||
};
|
||||
|
||||
const approvalSteps = booking.approvalSteps ?? [];
|
||||
const approvedCount = approvalSteps.filter((s) => s.status === "APPROVED").length;
|
||||
const totalSteps = approvalSteps.length;
|
||||
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Booking Detail"
|
||||
description="Inspect booking metadata, operational notes, and fulfillment progress for internal teams."
|
||||
/>
|
||||
<div style={detailStyles.page}>
|
||||
<Container size="xxl" py="lg">
|
||||
<BookingDetailToolbar onBack={() => navigate(-1)} />
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
{ label: booking.reference },
|
||||
]}
|
||||
/>
|
||||
{/*
|
||||
<BookingDetailHeader
|
||||
booking={booking}
|
||||
approvedCount={approvedCount}
|
||||
totalSteps={totalSteps}
|
||||
/> */}
|
||||
|
||||
<BookingLifecycleStepper status={booking.status} />
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingRouteCard booking={booking} />
|
||||
<BookingContainersCard containers={booking.bookingContainers ?? []} />
|
||||
<BookingApprovalCard steps={approvalSteps} approvedCount={approvedCount} />
|
||||
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — summary sidebar */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingPaymentCard
|
||||
totalAmount={booking.totalAmount}
|
||||
currency={booking.paymentCurrency}
|
||||
paymentStatus={booking.paymentStatus}
|
||||
/>
|
||||
<BookingFactsCard booking={booking} />
|
||||
<BookingDocumentsCard files={booking.files ?? []} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,111 +1,110 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, FileSignature, Package } from "lucide-react";
|
||||
import {
|
||||
Anchor,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Package,
|
||||
FileSignature,
|
||||
RefreshCw,
|
||||
Train,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
Container,
|
||||
Stack,
|
||||
Grid,
|
||||
Center,
|
||||
Loader,
|
||||
Text,
|
||||
Paper,
|
||||
Button,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||
import {
|
||||
bookingGlass,
|
||||
bookingSurface,
|
||||
} from "@/components/bookings/booking-ui.styles";
|
||||
detailStyles,
|
||||
BookingRequestHero,
|
||||
BookingRouteServiceCard,
|
||||
BookingMileServicesCard,
|
||||
BookingCargoCard,
|
||||
BookingContractSummaryCard,
|
||||
} from "@/components/bookings/detail";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import {
|
||||
useBookingDetail,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Separator,
|
||||
} from "@edr/ui-common";
|
||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: booking, isLoading, isError, refetch, isFetching } =
|
||||
useBookingDetail(id);
|
||||
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8">
|
||||
<Loader2 className="size-10 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Loading booking…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Box style={detailStyles.page}>
|
||||
<Center mih="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader color="green" />
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Loading booking…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !booking) {
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<div
|
||||
className={cn(
|
||||
bookingSurface.sectionCard,
|
||||
"mx-auto max-w-md p-12 text-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex size-16 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<Package className="size-8" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-xl font-bold text-foreground">
|
||||
<Box style={detailStyles.page}>
|
||||
<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-green-0)",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
}}
|
||||
>
|
||||
<Package size={32} />
|
||||
</Box>
|
||||
</Center>
|
||||
<Text fw={700} size="lg" mt="lg">
|
||||
Booking not found
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
This request may have been removed or the link is invalid.
|
||||
</p>
|
||||
</Text>
|
||||
<Button
|
||||
className="mt-6 gap-2"
|
||||
variant="outline"
|
||||
variant="default"
|
||||
mt="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to booking requests
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
const amount = Number(booking.totalAmount);
|
||||
const showContractButton = [
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
].includes(booking.status);
|
||||
const showApprovalCard =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<Box style={detailStyles.page}>
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
@@ -113,381 +112,66 @@ export default function BookingRequestDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={bookingSurface.detailHero}>
|
||||
<div className={bookingSurface.heroGlow} />
|
||||
<div className={bookingSurface.heroSheen} />
|
||||
<div className="relative p-6 sm:p-8">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to list
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-16 shrink-0 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
<Stack gap="lg" mt="sm">
|
||||
<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}
|
||||
/>
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingRouteServiceCard
|
||||
booking={booking}
|
||||
originLabel={row.originLabel}
|
||||
destinationLabel={row.destinationLabel}
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<Package className="size-7" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Booking reference
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
||||
{booking.reference}
|
||||
</h1>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</div>
|
||||
{booking.nextStep && (
|
||||
<NextStepBanner nextStep={booking.nextStep} className="max-w-xl" />
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
||||
<Building2 className="size-4 opacity-70" />
|
||||
{row.customerLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Calendar className="size-4 opacity-70" />
|
||||
Scheduled {booking.scheduledDate}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="size-4 opacity-70" />
|
||||
Created{" "}
|
||||
{new Date(booking.createdAt).toLocaleDateString(undefined, {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-3 sm:items-end">
|
||||
<div className={cn(bookingSurface.valueCard, "min-w-[12rem] text-right")}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Total value
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums text-foreground">
|
||||
{booking.paymentCurrency}{" "}
|
||||
{amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{booking.paymentStatus}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 self-end border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||
disabled={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-4", isFetching && "animate-spin")}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookingWorkflowStepper
|
||||
status={booking.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
titleColor={statusMeta.color}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12 xl:gap-8">
|
||||
<div className="flex flex-col gap-6 xl:col-span-8">
|
||||
<RouteCard booking={booking} row={row} />
|
||||
<MileCard booking={booking} />
|
||||
<CargoCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<SectionShell
|
||||
icon={<Anchor className="size-4" />}
|
||||
title="Contract summary"
|
||||
subtitle="Generated terms"
|
||||
>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/50 bg-muted/10 p-4 font-mono text-xs leading-relaxed text-muted-foreground backdrop-blur-sm">
|
||||
{booking.contractSummary}
|
||||
</pre>
|
||||
</SectionShell>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
bookingSurface.stickySidebar,
|
||||
"xl:col-span-4",
|
||||
)}
|
||||
>
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||
{["CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"].includes(
|
||||
booking.status,
|
||||
) && (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className="px-5 py-4">
|
||||
<Button
|
||||
className="w-full gap-2 shadow-sm"
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||
}
|
||||
>
|
||||
<FileSignature className="size-4" />
|
||||
View & sign contract
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE") && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionShell({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className={bookingSurface.sectionIcon}>{icon}</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={bookingSurface.sectionBody}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
booking,
|
||||
row,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
row: ReturnType<typeof toBookingListRow>;
|
||||
}) {
|
||||
return (
|
||||
<SectionShell
|
||||
icon={<Train className="size-4" />}
|
||||
title="Route & service"
|
||||
subtitle="Corridor and service level"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-emerald-500/20 p-5 backdrop-blur-sm md:flex-row md:items-center md:justify-between",
|
||||
bookingGlass.activeTab,
|
||||
)}
|
||||
>
|
||||
<RouteEndpoint label="Origin" station={row.originLabel} />
|
||||
<div className="flex flex-col items-center gap-2 px-4">
|
||||
<div className={cn("flex size-10 items-center justify-center rounded-full", bookingGlass.iconWellGreen)}>
|
||||
<Train className="size-5 text-black" strokeWidth={1.75} />
|
||||
</div>
|
||||
<ArrowRight className="size-5 rotate-90 text-muted-foreground md:rotate-0" />
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/50 bg-background/50 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
>
|
||||
{booking.serviceType?.label ??
|
||||
booking.serviceType?.code ??
|
||||
"Rail service"}
|
||||
</Badge>
|
||||
</div>
|
||||
<RouteEndpoint label="Destination" station={row.destinationLabel} />
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<MetricTile label="Trade direction" value={booking.tradeDirection} />
|
||||
<MetricTile label="Freight type" value={booking.freightType} />
|
||||
<MetricTile
|
||||
label="Equipment return"
|
||||
value={booking.equipmentReturn ?? "—"}
|
||||
/>
|
||||
{booking.shippingLine && (
|
||||
<MetricTile
|
||||
label="Shipping line"
|
||||
value={
|
||||
booking.shippingLine.label ??
|
||||
booking.shippingLine.name ??
|
||||
booking.shippingLine.code ??
|
||||
"—"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function MileCard({ booking }: { booking: BookingDetail }) {
|
||||
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SectionShell
|
||||
icon={<Truck className="size-4" />}
|
||||
title="Mile services"
|
||||
subtitle="First and last mile"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{booking.firstMilePickupAddress && (
|
||||
<MetricTile
|
||||
label="First mile pickup"
|
||||
value={booking.firstMilePickupAddress}
|
||||
/>
|
||||
)}
|
||||
{booking.lastMileDeliveryAddress && (
|
||||
<MetricTile
|
||||
label="Last mile delivery"
|
||||
value={booking.lastMileDeliveryAddress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CargoCard({ booking }: { booking: BookingDetail }) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
return (
|
||||
<SectionShell
|
||||
icon={<Package className="size-4" />}
|
||||
title="Cargo specifications"
|
||||
subtitle="Freight and containers"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricTile
|
||||
label="Cargo type"
|
||||
value={booking.cargoType?.label ?? booking.freightType}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Total VGM"
|
||||
value={`${booking.cargoTotalWeightVgm} tons`}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
value={booking.isHazardous ? "Yes" : "No"}
|
||||
highlight={booking.isHazardous}
|
||||
/>
|
||||
</div>
|
||||
{containers.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-5" />
|
||||
<div className="overflow-hidden rounded-lg border border-border/50 backdrop-blur-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/20 text-left text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-4 py-3">Container type</th>
|
||||
<th className="px-4 py-3">Qty</th>
|
||||
<th className="px-4 py-3">VGM / unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{containers.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="border-b border-border/60 last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{c.containerType?.label ??
|
||||
c.containerType?.code ??
|
||||
c.containerTypeId}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums text-muted-foreground">
|
||||
{c.quantity}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums text-muted-foreground">
|
||||
{c.vgmPerUnitTons} t
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteEndpoint({
|
||||
label,
|
||||
station,
|
||||
}: {
|
||||
label: string;
|
||||
station: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]">
|
||||
<div className={bookingSurface.sectionIconLg}>
|
||||
<MapPin className="size-5" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="truncate text-sm font-semibold text-foreground">{station}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTile({
|
||||
label,
|
||||
value,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
bookingSurface.metricTile,
|
||||
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm font-medium leading-snug text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@ import {
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
Stack,
|
||||
Group,
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Badge as MantineBadge,
|
||||
Button as MantineButton,
|
||||
ThemeIcon,
|
||||
Paper,
|
||||
} from "@mantine/core";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
@@ -26,12 +40,7 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
||||
import {
|
||||
bookingGlass,
|
||||
bookingInput,
|
||||
bookingSurface,
|
||||
bookingTable,
|
||||
} from "@/components/bookings/booking-ui.styles";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
|
||||
@@ -237,168 +246,195 @@ export default function BookingRequestsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
|
||||
<Container size="xxl" py="xl">
|
||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
|
||||
|
||||
<div className={bookingSurface.hero}>
|
||||
<div className={bookingSurface.heroGlow} />
|
||||
<div className={bookingSurface.heroSheen} />
|
||||
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-14 shrink-0 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<Inbox className="size-6" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Operations
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
||||
Booking requests
|
||||
</h1>
|
||||
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||
Track bookings from submission through payment and operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||
disabled={isFetching}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-4", isFetching && "animate-spin")}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookingStatGrid
|
||||
items={[
|
||||
{
|
||||
label: "In queue",
|
||||
value: statValue(metrics?.inQueue),
|
||||
hint: "Total matching filter",
|
||||
icon: LayoutList,
|
||||
},
|
||||
{
|
||||
label: "On this page",
|
||||
value: statValue(metrics?.onThisPage),
|
||||
hint: "Current page",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: statValue(metrics?.needsAction),
|
||||
hint: "Submitted or pending approval",
|
||||
icon: Clock,
|
||||
accent:
|
||||
!summaryLoading && (metrics?.needsAction ?? 0) > 0
|
||||
? "amber"
|
||||
: "default",
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: statValue(metrics?.urgent),
|
||||
hint: "High priority score",
|
||||
icon: AlertCircle,
|
||||
accent:
|
||||
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<BookingStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
{/*
|
||||
<Card
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
mb="xl"
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<div className={bookingSurface.panel}>
|
||||
<div className={bookingSurface.panelToolbar}>
|
||||
<div className="relative min-w-[12rem] flex-1 sm:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search reference or customer…"
|
||||
className={bookingInput.search}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"hidden rounded-md border border-border/50 bg-background/50 px-2.5 py-1 text-xs text-muted-foreground backdrop-blur-sm sm:inline",
|
||||
)}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group gap="md" align="flex-start">
|
||||
<ThemeIcon
|
||||
size="lg"
|
||||
radius="lg"
|
||||
color="green"
|
||||
variant="light"
|
||||
>
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Inbox size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={8}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
Operations
|
||||
</Text>
|
||||
<Title order={1} size="h2">
|
||||
Booking Requests
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw="500px">
|
||||
Track bookings from submission through payment and operations. Monitor status, prioritize urgent bookings, and manage approvals.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<MantineButton
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<RefreshCw size={18} />}
|
||||
disabled={isFetching}
|
||||
onClick={handleRefresh}
|
||||
loading={isFetching}
|
||||
>
|
||||
Refresh
|
||||
</MantineButton>
|
||||
</Group>
|
||||
</Card> */}
|
||||
|
||||
{showEmpty ? (
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
hasSearch={hasSearch}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<div className={bookingSurface.tableWrap}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={handleRowClick}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
<div className="mt-6"></div>
|
||||
<Stack gap="lg">
|
||||
<BookingStatGrid
|
||||
items={[
|
||||
{
|
||||
label: "In queue",
|
||||
value: statValue(metrics?.inQueue),
|
||||
hint: "Total matching filter",
|
||||
icon: LayoutList,
|
||||
},
|
||||
{
|
||||
label: "On this page",
|
||||
value: statValue(metrics?.onThisPage),
|
||||
hint: "Current page",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: statValue(metrics?.needsAction),
|
||||
hint: "Submitted or pending approval",
|
||||
icon: Clock,
|
||||
accent:
|
||||
!summaryLoading && (metrics?.needsAction ?? 0) > 0
|
||||
? "amber"
|
||||
: "default",
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: statValue(metrics?.urgent),
|
||||
hint: "High priority score",
|
||||
icon: AlertCircle,
|
||||
accent:
|
||||
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
scrollBehavior: "smooth",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: "min-content", display: "inline-block", width: "100%" }}>
|
||||
<BookingStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName={cn(
|
||||
"border-0 shadow-none",
|
||||
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||
)}
|
||||
footer={DataTableFooter}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
<Card
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{showEmpty ? (
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
hasSearch={hasSearch}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={handleRowClick}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName={cn(
|
||||
"border-0 shadow-none",
|
||||
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||
)}
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user