mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -46,6 +46,8 @@ export function ContractCustomerAction({
|
||||
label={action.label}
|
||||
size={size}
|
||||
urgent={action.urgent}
|
||||
waiting={action.waiting}
|
||||
icon={action.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user