feat(bookings): add clearance review functionality and update UI components

This commit is contained in:
Marshal
2026-06-25 02:08:31 +00:00
parent da01b4d453
commit 6b6ca49e29
7 changed files with 168 additions and 54 deletions

View File

@@ -8,6 +8,7 @@ import { useAuth } from "@/auth/useAuth";
import { import {
getNextPendingApprovalStep, getNextPendingApprovalStep,
isAllocateAction, isAllocateAction,
isClearanceNavAction,
isContractNavAction, isContractNavAction,
listRowHasActions, listRowHasActions,
type BookingActionContext, type BookingActionContext,
@@ -38,6 +39,7 @@ export function BookingActionsMenu({
reference: row.reference, reference: row.reference,
approvalSteps: row.approvalSteps, approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus, schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
}; };
const flow = useBookingActionDialog(row.id, context); const flow = useBookingActionDialog(row.id, context);
@@ -46,10 +48,15 @@ export function BookingActionsMenu({
const goToContract = () => const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`); navigate(`/dashboard/booking-requests/${row.id}/contract`);
const goToClearanceTab = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
const handleAction = (action: (typeof actions)[number]) => { const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.(); onSuppressRowClick?.();
if (isContractNavAction(action.id)) { if (isContractNavAction(action.id)) {
goToContract(); goToContract();
} else if (isClearanceNavAction(action.id)) {
goToClearanceTab();
} else if (isAllocateAction(action.id)) { } else if (isAllocateAction(action.id)) {
onAllocateBooking?.(); onAllocateBooking?.();
} else { } else {

View File

@@ -44,9 +44,9 @@ const STATUS_META: Record<
Freight.DocumentReviewStatus, Freight.DocumentReviewStatus,
{ label: string; color: string } { label: string; color: string }
> = { > = {
APPROVED: { label: "Approved", color: "edr-green" }, APPROVED: { label: "Approved", color: "edr-blue" },
QUERIED: { label: "Queried", color: "red" }, QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "edr-slate" }, PENDING: { label: "Pending", color: "edr-accent" },
}; };
/** /**
@@ -142,7 +142,7 @@ export function ClearanceReviewSection({
if (isLoading || !clearance) { if (isLoading || !clearance) {
return ( return (
<Group justify="center" py="xl" gap={10}> <Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" /> <Loader size="sm" color="edr-blue" />
<Text c="dimmed">Loading clearance</Text> <Text c="dimmed">Loading clearance</Text>
</Group> </Group>
); );
@@ -165,15 +165,15 @@ export function ClearanceReviewSection({
<Box> <Box>
<Progress <Progress
value={stats.pct} value={stats.pct}
color="edr-green" color="edr-blue"
radius="xl" radius="xl"
size="sm" size="sm"
mb={6} mb={6}
/> />
<Group gap="lg"> <Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} /> <StatPill color="edr-blue" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} /> <StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="edr-slate" label="Pending" value={stats.pending} /> <StatPill color="edr-accent" label="Pending" value={stats.pending} />
</Group> </Group>
</Box> </Box>
)} )}
@@ -219,13 +219,13 @@ export function ClearanceReviewSection({
icon={Upload} icon={Upload}
title="Customs output documents" title="Customs output documents"
subtitle="Upload the cleared/customs paperwork to hand back to the customer." subtitle="Upload the cleared/customs paperwork to hand back to the customer."
accent="edr-blue" accent="edr-accent"
> >
<Stack gap={10}> <Stack gap={10}>
{glDocs.map((doc) => ( {glDocs.map((doc) => (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap"> <Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-blue-6)" /> <FileText size={16} color="var(--mantine-color-edr-accent-6)" />
<Text fz="13px" c="edr-text" truncate> <Text fz="13px" c="edr-text" truncate>
{doc.label} {doc.label}
{doc.required ? " *" : ""} {doc.required ? " *" : ""}
@@ -239,7 +239,7 @@ export function ClearanceReviewSection({
href={doc.file.url} href={doc.file.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
c="edr-blue" c="edr-accent"
style={{ display: "flex" }} style={{ display: "flex" }}
> >
<Download size={15} /> <Download size={15} />
@@ -261,7 +261,7 @@ export function ClearanceReviewSection({
{...props} {...props}
size="compact-xs" size="compact-xs"
variant="light" variant="light"
color="edr-green" color="edr-accent"
leftSection={<Upload size={13} />} leftSection={<Upload size={13} />}
> >
{outputFiles[doc.fileKey] ? "Selected" : "Upload"} {outputFiles[doc.fileKey] ? "Selected" : "Upload"}
@@ -275,7 +275,7 @@ export function ClearanceReviewSection({
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button <Button
variant="light" variant="light"
color="edr-green" color="edr-accent"
radius="md" radius="md"
leftSection={<Upload size={15} />} leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0} disabled={Object.keys(outputFiles).length === 0}
@@ -301,7 +301,7 @@ export function ClearanceReviewSection({
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon <ThemeIcon
variant="light" variant="light"
color={clearance.allApproved ? "edr-green" : "gray"} color={clearance.allApproved ? "edr-blue" : "gray"}
radius="md" radius="md"
size={28} size={28}
> >
@@ -314,7 +314,7 @@ export function ClearanceReviewSection({
</Text> </Text>
</Group> </Group>
<Button <Button
color="edr-green" color="edr-blue"
radius="md" radius="md"
leftSection={<CheckCircle2 size={16} />} leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved} disabled={!clearance.allApproved}
@@ -391,7 +391,7 @@ function DocReviewCard({
status === "QUERIED" status === "QUERIED"
? "var(--mantine-color-red-2)" ? "var(--mantine-color-red-2)"
: status === "APPROVED" : status === "APPROVED"
? "var(--mantine-color-edr-green-2)" ? "var(--mantine-color-edr-blue-2)"
: "var(--mantine-color-edr-border-6)", : "var(--mantine-color-edr-border-6)",
}} }}
> >
@@ -471,7 +471,7 @@ function DocReviewCard({
</Button> </Button>
<Button <Button
size="compact-sm" size="compact-sm"
color="edr-green" color="edr-blue"
radius="md" radius="md"
leftSection={<CheckCircle2 size={14} />} leftSection={<CheckCircle2 size={14} />}
disabled={busy} disabled={busy}

View File

@@ -32,6 +32,7 @@ export type BookingActionId =
| "rejectApproval" | "rejectApproval"
| "viewContract" | "viewContract"
| "signContractStaff" | "signContractStaff"
| "reviewClearance"
| "allocateBooking" | "allocateBooking"
| "startTransit" | "startTransit"
| "complete" | "complete"
@@ -69,6 +70,7 @@ export type BookingActionContext = Pick<
| "approvalSteps" | "approvalSteps"
| "reference" | "reference"
| "schedulingStatus" | "schedulingStatus"
| "customsClearingEnabled"
>; >;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([ const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
@@ -250,6 +252,20 @@ const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
primary: true, primary: true,
}; };
// Opens the booking detail straight on the Clearance tab so Marketing can
// review the customer's clearance documents (non-customs bookings only).
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
id: "reviewClearance",
label: "Review clearance",
shortLabel: "Clearance",
description: "Approve or query the customer's clearance documents",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: ShieldCheck,
primary: true,
};
function withCancel(actions: BookingActionDef[]): BookingActionDef[] { function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
return [...actions, CANCEL_ACTION]; return [...actions, CANCEL_ACTION];
} }
@@ -261,6 +277,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval, rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
viewContract: FREIGHT_PERMS.bookings.view, viewContract: FREIGHT_PERMS.bookings.view,
signContractStaff: FREIGHT_PERMS.bookings.signStaff, signContractStaff: FREIGHT_PERMS.bookings.signStaff,
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
startTransit: FREIGHT_PERMS.bookings.operations, startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations, complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations, operationAccept: FREIGHT_PERMS.bookings.operations,
@@ -359,6 +376,14 @@ export function getBookingActions(
}, },
]; ];
break; break;
case "AWAITING_DOCUMENTS":
case "DOCUMENTS_UNDER_REVIEW":
// Marketing reviews non-customs clearance here; customs bookings are
// handled in the Global Logistics clearance queue, not the booking list.
actions = ctx.customsClearingEnabled
? [CANCEL_ACTION]
: withCancel([REVIEW_CLEARANCE_ACTION]);
break;
case "OPERATION_REQUEST_PENDING": case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS); actions = withCancel(OPERATION_REVIEW_ACTIONS);
break; break;
@@ -446,11 +471,17 @@ export function isAllocateAction(id: BookingActionId): boolean {
return id === "allocateBooking"; return id === "allocateBooking";
} }
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
export function isClearanceNavAction(id: BookingActionId): boolean {
return id === "reviewClearance";
}
export function listRowHasActions( export function listRowHasActions(
row: { row: {
status: BookingStatus; status: BookingStatus;
paymentCurrency: string; paymentCurrency: string;
approvalSteps?: BookingApprovalStep[] | null; approvalSteps?: BookingApprovalStep[] | null;
customsClearingEnabled?: boolean;
}, },
user?: AuthUser | null, user?: AuthUser | null,
): boolean { ): boolean {
@@ -461,6 +492,7 @@ export function listRowHasActions(
reference: "", reference: "",
approvalSteps: row.approvalSteps ?? undefined, approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.status, schedulingStatus: row.status,
customsClearingEnabled: row.customsClearingEnabled,
}, },
user, user,
); );

View File

@@ -44,6 +44,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
governmentInstitution: booking.governmentInstitution ?? null, governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null, consolidationPartnerId: booking.consolidationPartnerId ?? null,
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null, consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
customsClearingEnabled: booking.customsClearingEnabled ?? false,
createdAt: booking.createdAt, createdAt: booking.createdAt,
}; };
} }

View File

@@ -1,11 +1,18 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ArrowLeft, FileSignature, Package } from "lucide-react"; import {
ArrowLeft,
FileSignature,
LayoutGrid,
Package,
ShieldCheck,
} from "lucide-react";
import { import {
Container, Container,
Stack, Stack,
Grid, Grid,
Center, Center,
Loader, Loader,
Tabs,
Text, Text,
Paper, Paper,
Button, Button,
@@ -34,6 +41,7 @@ import {
import { WarehouseInfoCard } from "@/components/warehouses"; import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { BookingDetail } from "@/types/booking";
import { downloadBookingFile } from "@/services/files.service"; import { downloadBookingFile } from "@/services/files.service";
import { import {
useBookingDetail, useBookingDetail,
@@ -53,6 +61,7 @@ const SIGNATURE_FILE_CODES = new Set([
export default function BookingRequestDetailPage() { export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { const {
data: booking, data: booking,
isLoading, isLoading,
@@ -143,6 +152,23 @@ export default function BookingRequestDetailPage() {
booking.status === "PENDING_APPROVAL" || booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"; 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,
);
const requestedTab = searchParams.get("tab");
const activeTab =
requestedTab === "clearance" && showClearanceTab ? "clearance" : "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 ( return (
<PageContainer> <PageContainer>
<Breadcrumbs <Breadcrumbs
@@ -173,37 +199,52 @@ export default function BookingRequestDetailPage() {
)} )}
<Grid gap="lg"> <Grid gap="lg">
{/* LEFT — primary content */} {/* LEFT — primary content, split into tabs to keep each view focused */}
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg"> {showClearanceTab ? (
{/* Non-customs clearance is reviewed here by Marketing; customs <Tabs
bookings are handled in the Global Logistics clearance queue. */} value={activeTab}
{!booking.customsClearingEnabled && onChange={setActiveTab}
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW"].includes( variant="pills"
booking.status, color="edr-blue"
) ? ( keepMounted={false}
<ClearanceReviewSection >
bookingId={booking.id} <Tabs.List mb="lg">
onChanged={() => refetch()} <Tabs.Tab
/> value="overview"
) : null} leftSection={<LayoutGrid size={16} />}
<BookingRouteServiceCard >
Overview
</Tabs.Tab>
<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>
<Tabs.Panel value="clearance">
<ClearanceReviewSection
bookingId={booking.id}
onChanged={() => refetch()}
/>
</Tabs.Panel>
</Tabs>
) : (
<OverviewPanel
booking={booking} booking={booking}
originLabel={row.originLabel} row={row}
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={handleDownloadFile} onDownload={handleDownloadFile}
/> />
</Stack> )}
</Grid.Col> </Grid.Col>
{/* RIGHT — sticky action / summary rail */} {/* RIGHT — sticky action / summary rail */}
@@ -245,3 +286,35 @@ export default function BookingRequestDetailPage() {
</PageContainer> </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>
);
}

View File

@@ -64,7 +64,7 @@ export default function DocumentClearanceDetailPage() {
return ( return (
<PageContainer> <PageContainer>
<Group justify="center" py={80} gap={10}> <Group justify="center" py={80} gap={10}>
<Loader color="edr-green" /> <Loader color="edr-blue" />
<Text c="dimmed">Loading clearance</Text> <Text c="dimmed">Loading clearance</Text>
</Group> </Group>
</PageContainer> </PageContainer>
@@ -103,7 +103,7 @@ export default function DocumentClearanceDetailPage() {
clearance.allApproved ? ( clearance.allApproved ? (
<Badge <Badge
variant="light" variant="light"
color="edr-green" color="edr-blue"
radius="sm" radius="sm"
leftSection={<CheckCircle2 size={13} />} leftSection={<CheckCircle2 size={13} />}
> >
@@ -112,7 +112,7 @@ export default function DocumentClearanceDetailPage() {
) : ( ) : (
<Badge <Badge
variant="light" variant="light"
color="edr-blue" color="edr-accent"
radius="sm" radius="sm"
leftSection={<Clock size={13} />} leftSection={<Clock size={13} />}
> >
@@ -133,13 +133,13 @@ export default function DocumentClearanceDetailPage() {
{/* RIGHT — sticky progress gauge */} {/* RIGHT — sticky progress gauge */}
<Grid.Col span={{ base: 12, lg: 4 }}> <Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}> <Box style={{ position: "sticky", top: 24 }}>
<SectionCard icon={PackageCheck} title="Review progress"> <SectionCard icon={PackageCheck} title="Review progress" accent="blue">
<Stack align="center" gap="sm"> <Stack align="center" gap="sm">
<RingProgress <RingProgress
size={140} size={140}
thickness={12} thickness={12}
roundCaps roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]} sections={[{ value: stats.pct, color: "edr-blue" }]}
label={ label={
<Stack gap={0} align="center"> <Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}> <Text fw={800} fz={26} lh={1}>
@@ -153,7 +153,7 @@ export default function DocumentClearanceDetailPage() {
/> />
<Group gap="lg" justify="center"> <Group gap="lg" justify="center">
<ProgressStat <ProgressStat
color="edr-green" color="edr-blue"
label="Approved" label="Approved"
value={stats.approved} value={stats.approved}
/> />
@@ -163,7 +163,7 @@ export default function DocumentClearanceDetailPage() {
value={stats.queried} value={stats.queried}
/> />
<ProgressStat <ProgressStat
color="edr-slate" color="edr-accent"
label="Pending" label="Pending"
value={stats.pending} value={stats.pending}
/> />
@@ -199,7 +199,7 @@ function ClearanceHero({
<Paper withBorder radius="md" p="lg"> <Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg"> <Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}> <Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}> <ThemeIcon variant="light" color="edr-blue" radius="md" size={52}>
<ShieldCheck size={26} /> <ShieldCheck size={26} />
</ThemeIcon> </ThemeIcon>
<Box style={{ minWidth: 0 }}> <Box style={{ minWidth: 0 }}>
@@ -219,7 +219,7 @@ function ClearanceHero({
<Badge <Badge
size="sm" size="sm"
variant="light" variant="light"
color="edr-green" color="edr-accent"
radius="sm" radius="sm"
leftSection={<ShieldCheck size={12} />} leftSection={<ShieldCheck size={12} />}
> >
@@ -248,7 +248,7 @@ function ClearanceHero({
{stats.approved}/{stats.total} {stats.approved}/{stats.total}
</Text> </Text>
</Group> </Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" /> <Progress value={stats.pct} color="edr-blue" radius="xl" size="md" />
</Box> </Box>
</Group> </Group>
</Paper> </Paper>

View File

@@ -221,5 +221,6 @@ export interface BookingListRow {
governmentInstitution?: string | null; governmentInstitution?: string | null;
consolidationPartnerId?: string | null; consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null; consolidationPartnerReference?: string | null;
customsClearingEnabled?: boolean;
createdAt: string; createdAt: string;
} }