feat: enhance contract clearance process with linked booking details

- Added  and  to  for better visibility of GL-created shipment bookings.
- Implemented  method in  to fetch the latest clearance phase for contracts, improving list responses.
- Introduced  property in the  entity to store the latest clearance cycle's phase.
- Updated  to surface linked booking information in the clearance view.
- Created  component to display detailed container information in booking details.
- Refactored booking actions to remove contract-related actions from the booking request page.
- Enhanced the  component to reflect the current phase of clearance actions.
- Updated UI components to provide clearer messaging regarding the status of clearance and linked bookings.
- Adjusted action handling in  to include duty payment actions.
- Improved the  to show hints for each phase of the clearance process.
This commit is contained in:
Marshal
2026-07-03 21:04:46 +00:00
parent c3f06b6aa9
commit 85c2fd1428
22 changed files with 537 additions and 204 deletions

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Text, type ButtonProps } from "@mantine/core";
import { AlertCircle, Upload } from "lucide-react";
import { AlertCircle, Upload, type LucideIcon } from "lucide-react";
import { api } from "@/services/api";
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
@@ -13,6 +13,10 @@ interface ContractClearanceActionProps {
label?: string;
size?: ButtonProps["size"];
urgent?: boolean;
/** GL's turn — render as a calm status button, not a call to action. */
waiting?: boolean;
/** Icon override from the phase-aware action derivation. */
icon?: LucideIcon;
}
export function ContractClearanceAction({
@@ -20,6 +24,8 @@ export function ContractClearanceAction({
label: labelProp,
size = "xs",
urgent = false,
waiting = false,
icon: iconProp,
}: ContractClearanceActionProps) {
const [opened, { open, close }] = useDisclosure(false);
@@ -38,7 +44,13 @@ export function ContractClearanceAction({
return urgent ? "Upload clearance" : "Manage clearance";
}, [labelProp, clearance, urgent]);
const Icon = urgent || label.includes("Update") ? AlertCircle : Upload;
const Icon =
iconProp ?? (urgent || label.includes("Update") ? AlertCircle : Upload);
// Urgent (customer's turn) = filled orange so it stands out among the green
// actions; waiting (GL's turn) = calm subtle gray; default = brand green.
const color = urgent ? "orange" : waiting ? "gray" : "edr-green";
const variant = waiting ? "light" : "filled";
return (
<ModalSafeWrapper>
@@ -47,7 +59,8 @@ export function ContractClearanceAction({
radius="md"
fw={700}
fz={13}
color="edr-green"
color={color}
variant={variant}
leftSection={<Icon size={14} />}
onClick={(e) => {
e.stopPropagation();

View File

@@ -46,6 +46,8 @@ export function ContractCustomerAction({
label={action.label}
size={size}
urgent={action.urgent}
waiting={action.waiting}
icon={action.icon}
/>
);
}

View File

@@ -4,8 +4,10 @@ import {
CreditCard,
Eye,
FileSignature,
Hourglass,
PackagePlus,
PencilLine,
Receipt,
RotateCcw,
Upload,
} from "lucide-react";
@@ -61,6 +63,8 @@ export type ContractCustomerAction =
primary: boolean;
icon: LucideIcon;
urgent: boolean;
/** True when it's GL's turn — render calm/informational, not a call to action. */
waiting?: boolean;
}
| {
type: "pay";
@@ -126,14 +130,56 @@ export function deriveContractCustomerAction(
const clr = contractNeedsClearanceAction(contract);
if (clr.show) {
return {
type: "clearance",
contractId: id,
label: clr.urgent ? "Upload clearance" : "Update clearance",
primary: true,
icon: Upload,
urgent: clr.urgent,
};
// Refine the generic clearance action by the persisted clearance phase so
// the button says what the customer actually has to do right now (e.g.
// "Pay duty & upload slip" during CUSTOMER_DUTY, not "Update clearance").
const phase = contract.clearancePhase ?? null;
switch (phase) {
case "CUSTOMER_INTAKE":
return {
type: "clearance",
contractId: id,
label: "Upload clearance documents",
primary: true,
icon: Upload,
urgent: true,
};
case "CUSTOMER_DUTY":
return {
type: "clearance",
contractId: id,
label: "Pay duty & upload slip",
primary: true,
icon: Receipt,
urgent: true,
};
case "GL_ET_REVIEW":
case "GL_DJ_COLLECTION":
case "GL_ET_OUTPUT":
case "GL_ET_POST_CLEARANCE":
case "GL_DJ_LOADING":
case "POST_TRANSIT":
// GL's turn — nothing for the customer to do; show a calm status.
return {
type: "clearance",
contractId: id,
label: "Clearance in progress",
primary: false,
icon: Hourglass,
urgent: false,
waiting: true,
};
default:
// No persisted phase (legacy / early cycles) — keep the status-derived label.
return {
type: "clearance",
contractId: id,
label: clr.urgent ? "Upload clearance" : "Update clearance",
primary: true,
icon: Upload,
urgent: clr.urgent,
};
}
}
if (

View File

@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
export interface ActionItem {
id: string;
/** What the customer must do — drives the icon, label and modal. */
kind: "clearance" | "sign" | "book" | "pay";
kind: "clearance" | "duty" | "sign" | "book" | "pay";
/** The contract/booking reference for display. */
reference: string;
/** Short human description of the action. */

View File

@@ -8,6 +8,7 @@ import {
FilePlus2,
FileSignature,
PackagePlus,
Receipt,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -34,6 +35,7 @@ const KIND_META: Record<
{ icon: typeof Upload; label: string; color: string }
> = {
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
duty: { icon: Receipt, label: "Duty / tax", color: "orange" },
sign: { icon: FileSignature, label: "Sign", color: "blue" },
book: { icon: PackagePlus, label: "Book", color: "violet" },
pay: { icon: CreditCard, label: "Payment", color: "orange" },
@@ -96,6 +98,19 @@ export function ActionNeededSection({
const awaiting =
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
view?.clearanceStatus === "AWAITING_DOCUMENTS";
// Duty phase: the customer's task is paying duty/tax and uploading the
// slip — a distinct, money action, not a generic document upload.
if (view?.phase === "CUSTOMER_DUTY") {
out.push({
id: `duty-${c.id}`,
kind: "duty",
reference: c.reference,
description: "Duty / tax payment due — pay and upload the slip",
targetId: c.id,
urgent: true,
});
return;
}
// Only surface when there's something the customer can do: a query, or the
// contract is awaiting their (re)upload.
if (queried === 0 && !awaiting) return;
@@ -162,6 +177,10 @@ export function ActionNeededSection({
case "clearance":
setClearanceId(item.targetId);
break;
case "duty":
// Duty advice + payment-slip upload live on the contract detail page.
navigate(`/contracts/${item.targetId}`);
break;
case "pay":
setPayItem(item);
break;
@@ -249,6 +268,8 @@ export function ActionNeededSection({
leftSection={
item.kind === "clearance" ? (
<Upload size={14} />
) : item.kind === "duty" ? (
<Receipt size={14} />
) : (
<FilePlus2 size={14} />
)
@@ -256,13 +277,15 @@ export function ActionNeededSection({
>
{item.kind === "pay"
? "Pay now"
: item.kind === "sign"
? "Sign"
: item.kind === "book"
? "Book"
: item.urgent
? "Upload documents"
: "Upload"}
: item.kind === "duty"
? "Pay duty & upload slip"
: item.kind === "sign"
? "Sign"
: item.kind === "book"
? "Book"
: item.urgent
? "Upload documents"
: "Upload"}
</Button>
</Group>
);

View File

@@ -15,6 +15,18 @@ const PHASE_LABELS: Record<string, string> = {
POST_TRANSIT: "Transit",
};
/** One-line hint under each phase label, for the vertical layout. */
const PHASE_HINTS: Record<string, string> = {
CUSTOMER_INTAKE: "You upload the required clearance documents",
GL_ET_REVIEW: "Global Logistics reviews your documents in Ethiopia",
GL_DJ_COLLECTION: "Delivery order collected in Djibouti",
GL_ET_OUTPUT: "Customs declaration prepared",
CUSTOMER_DUTY: "You pay the assessed duty / tax",
GL_ET_POST_CLEARANCE: "Transit cleared and paperwork finalised",
GL_DJ_LOADING: "Cargo loaded for departure",
POST_TRANSIT: "In transit",
};
const IMPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
@@ -51,62 +63,92 @@ export function ClearancePhaseStepper({
const current = clearance?.phase ?? phases[0];
const activeIdx = phaseIndex(phases, current);
const dot = compact ? 26 : 30;
const rowGap = compact ? 18 : 24;
// Vertical timeline: every phase is a row, so all steps stay visible on any
// width without horizontal scrolling. The connector runs down between dots.
return (
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
<Stack gap={0}>
{phases.map((phase, index) => {
const isComplete = index < activeIdx;
const isActive = index === activeIdx;
const isLast = index === phases.length - 1;
const doneOrActive = isComplete || isActive;
return (
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: compact ? 28 : 34,
height: compact ? 28 : 34,
borderRadius: "50%",
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-3)",
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
}}
>
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
</Box>
<Text
size={compact ? "10px" : "xs"}
fw={isActive ? 600 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
</Stack>
<Group key={phase} gap={12} wrap="nowrap" align="flex-start">
{/* Dot + connector column */}
<Stack gap={0} align="center" style={{ flexShrink: 0, alignSelf: "stretch" }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: dot,
height: dot,
borderRadius: "50%",
flexShrink: 0,
background: isComplete
? BRAND_GREEN
: isActive
? "white"
: "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-3)",
color: isComplete
? "white"
: isActive
? BRAND_GREEN
: "var(--mantine-color-gray-5)",
}}
>
{isComplete ? (
<Check size={compact ? 14 : 16} strokeWidth={3} />
) : (
<Text size="xs" fw={700}>
{index + 1}
</Text>
)}
</Box>
{!isLast && (
<Box
style={{
width: 2,
flex: 1,
height: 2,
marginInline: 6,
marginBottom: compact ? 16 : 20,
minHeight: rowGap,
marginBlock: 4,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
background: isComplete
? BRAND_GREEN
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
</Stack>
{/* Label + hint */}
<Box pb={isLast ? 0 : rowGap} style={{ minWidth: 0, paddingTop: 3 }}>
<Text
size="sm"
fw={isActive ? 700 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
lh={1.2}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
{PHASE_HINTS[phase] && (
<Text size="xs" c="dimmed" mt={2} lh={1.3}>
{PHASE_HINTS[phase]}
</Text>
)}
</Box>
</Group>
);
})}
</Group>
</Stack>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
@@ -87,7 +87,36 @@ export function ContractClearanceWorkflowBanner({
onDownload={downloadWorkflowFile}
/>
{clearance.bookingReady ? (
{clearance.linkedBookingId ? (
<Alert color="green" variant="light" icon={<PackageCheck size={16} />}>
<Stack gap={6}>
<Text fz={13} fw={600} style={{ color: INK }}>
Shipment booking created
{clearance.linkedBookingReference
? ` · ${clearance.linkedBookingReference}`
: ""}
</Text>
<Text fz={12} c="dimmed">
Global Logistics has created your shipment booking
{clearance.linkedBookingStatus
? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})`
: ""}
. Track its progress from the booking.
</Text>
<Button
size="compact-sm"
variant="light"
color="green"
leftSection={<ArrowRight size={14} />}
component="a"
href={`/bookings/${clearance.linkedBookingId}`}
style={{ alignSelf: "flex-start" }}
>
View shipment booking
</Button>
</Stack>
</Alert>
) : clearance.bookingReady ? (
<Alert color="green" variant="light">
Clearance is complete. Global Logistics will create your shipment booking shortly.
</Alert>

View File

@@ -15,7 +15,8 @@ import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
const CONTRACT_TYPE_OPTIONS = [
{ value: "new", label: "New Contract" },
{ value: "renewal", label: "Contract Renewal" },
// Renewal is disabled for now — not yet available to customers.
{ value: "renewal", label: "Contract Renewal (coming soon)", disabled: true },
];
type ContractForm = UseFormReturn<