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

@@ -90,6 +90,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('bc.units', 'bcu')
.leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.train', 'train')
@@ -104,6 +105,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
.where('booking.id = :id', { id })
.addOrderBy('bcu.sort_order', 'ASC')
.leftJoinAndMapMany(
'booking.files',
FileRecord,

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { Booking } from './booking.entity';
import { BookingContainerUnit } from './booking-container-unit.entity';
@Entity({ schema: 'freight', name: 'booking_container' })
@Index(['bookingId'])
@@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity {
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
overweightExcessTons?: number | null;
/** The physical containers under this line — each with its own number + VGM. */
@OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer)
units?: BookingContainerUnit[];
}

View File

@@ -334,15 +334,19 @@ export class CompaniesController {
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
const files = await this.filesService.findByResource(companyId, "companies");
return files.map((f) => ({
id: f.id,
name: f.name,
code: f.code,
mimeType: f.mimeType,
size: f.size,
uploadedAt: f.createdAt,
url: f.url,
}));
return Promise.all(
files.map(async (f) => ({
id: f.id,
name: f.name,
code: f.code,
mimeType: f.mimeType,
size: f.size,
uploadedAt: f.createdAt,
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
// it so the file previews/downloads in the client.
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
})),
);
}
@Post(":companyId/documents")

View File

@@ -77,6 +77,9 @@ export interface ContractClearanceView {
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
exportClearanceFinalized?: boolean;
linkedBookingId?: string | null;
/** Reference + status of the GL-created shipment booking, once it exists. */
linkedBookingReference?: string | null;
linkedBookingStatus?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -284,13 +287,22 @@ export class ContractClearanceService {
);
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
// Once GL creates the shipment booking, surface its reference + status so the
// customer sees the concrete booking instead of a stale "will be created
// shortly" message. Reuse the export booking load; fetch for import too.
let linkedBookingReference: string | null = null;
let linkedBookingStatus: string | null = null;
if (cycle?.bookingId) {
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
}
}
@@ -326,6 +338,8 @@ export class ContractClearanceService {
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
exportClearanceFinalized: Boolean(cycle?.completedAt),
linkedBookingId: cycle?.bookingId ?? null,
linkedBookingReference,
linkedBookingStatus,
dutyAdvice,
workflowFiles,
t1,

View File

@@ -135,6 +135,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
// Attach the generated contract PDF to each row so list/home can offer a
// direct download. Loaded separately to keep pagination counts correct.
await this.attachContractFiles(items);
await this.attachClearancePhases(items);
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
@@ -173,6 +174,30 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
}
/**
* Attach each contract's persisted clearance phase (latest cycle's
* current_phase) so list consumers can show step-accurate customer actions
* ("Pay duty & upload slip" vs generic "Update clearance") without a
* per-contract clearance-view request. One query per page, like
* `attachContractFiles`.
*/
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
if (contracts.length === 0) return;
const ids = contracts.map((c) => c.id);
const rows: Array<{ contract_id: string; current_phase: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
FROM freight.contract_clearance_cycles
WHERE contract_id = ANY($1)
ORDER BY contract_id, cycle_number DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
for (const contract of contracts) {
contract.clearancePhase = byContract.get(contract.id) ?? null;
}
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('contract')

View File

@@ -254,4 +254,10 @@ export class Contract extends BaseEntity {
createForeignKeyConstraints: false,
})
files?: FileRecord[];
/**
* Latest clearance cycle's current_phase, attached by
* ContractsRepository.attachClearancePhases for list responses. Not a column.
*/
clearancePhase?: string | null;
}

View File

@@ -123,6 +123,16 @@ export class FilesService {
return this.filesRepository.findByResource(resourceId, resource);
}
/**
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
* `url` is an un-signed object path that a browser cannot fetch directly;
* callers that expose files for preview/download must sign them first.
*/
async signUrl(rawUrl: string, expirySeconds = 300): Promise<string> {
const objectName = this.minioService.getObjectNameFromUrl(rawUrl);
return this.minioService.getSignedUrl(objectName, expirySeconds);
}
async findByCode(
resourceId: string,
resource: string,

View File

@@ -1,5 +1,5 @@
import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import { Zap, Clock } from "lucide-react";
import { Stack, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -14,21 +14,11 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
/** Detail-page actions: primary staff-action toolbar. */
export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) {
const row = toBookingListRow(booking);
const { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
return null;
}
@@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
<BookingActionsMenu row={row} variant="toolbar" />
</Stack>
</SectionCard>
{status === "CONTRACT_READY" && (
<SectionCard icon={FileText} title="Documents">
<Button
variant="default"
leftSection={<Download size={16} />}
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
`contract-${booking.reference}.txt`,
)
}
>
Download contract
</Button>
</SectionCard>
)}
</Stack>
);
}

View File

@@ -0,0 +1,202 @@
import { useMemo } from "react";
import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react";
import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
export interface BookingContainerUnitsCardProps {
booking: BookingDetail;
}
interface FlatUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
typeLabel: string;
sizeFt?: number;
}
/**
* The physical container manifest: one row per container with its number, type,
* seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown
* bookings — when a line has no units the card falls back to the aggregate
* type/qty/weight so it still renders something for plain bookings.
*/
export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) {
const lines = booking.bookingContainers ?? [];
const units: FlatUnit[] = useMemo(
() =>
lines.flatMap((line) =>
(line.units ?? []).map((u) => ({
id: u.id,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber,
vgmTons: Number(u.vgmTons) || 0,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—",
sizeFt: line.containerType?.sizeFt,
})),
),
[lines],
);
// Container bookings only — bulk has no container manifest.
if (booking.freightType === "BULK" || lines.length === 0) return null;
const totalUnits = units.length;
const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0);
return (
<SectionCard
icon={Boxes}
title="Containers"
subtitle={
totalUnits > 0
? "Each physical container with its number and weight"
: "Per-container numbers were not captured for this booking"
}
accent="teal"
extra={
totalUnits > 0 ? (
<Badge color="teal" variant="light" radius="sm">
{totalUnits} container{totalUnits === 1 ? "" : "s"}
</Badge>
) : (
<Badge color="gray" variant="light" radius="sm">
{lines.length} line{lines.length === 1 ? "" : "s"}
</Badge>
)
}
>
{totalUnits > 0 ? (
<Stack gap="md">
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 40 }}>#</Table.Th>
<Table.Th>Container No.</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th ta="right">Weight (VGM)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((u, i) => (
<Table.Tr key={u.id}>
<Table.Td>
<Text size="sm" c="dimmed">
{i + 1}
</Text>
</Table.Td>
<Table.Td>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={26} radius="md" variant="light" color="teal">
<ContainerIcon size={15} />
</ThemeIcon>
<Text size="sm" fw={700} ff="monospace">
{u.containerNumber}
</Text>
{u.isReefer ? (
<ThemeIcon size={20} radius="sm" variant="light" color="blue" title="Reefer">
<Snowflake size={12} />
</ThemeIcon>
) : null}
{u.isHazardous ? (
<ThemeIcon size={20} radius="sm" variant="light" color="red" title="Hazardous">
<Flame size={12} />
</ThemeIcon>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm">{u.typeLabel}</Text>
{u.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{u.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c={u.sealNumber ? undefined : "dimmed"}>
{u.sealNumber || "—"}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{u.vgmTons.toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group
justify="space-between"
pt="sm"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text size="sm" fw={600} c="dimmed">
Total weight (VGM)
</Text>
<Text size="sm" fw={800} c="teal.7">
{totalVgm.toFixed(3)} t
</Text>
</Group>
</Stack>
) : (
// Fallback: no per-unit numbers — show the aggregate lines.
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
<Table.Th ta="right">Total VGM</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.map((line) => {
const perUnit = Number(line.vgmPerUnitTons) || 0;
return (
<Table.Tr key={line.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{line.containerType?.label ?? line.containerType?.code ?? "—"}
</Text>
{line.containerType?.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{line.containerType.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>{line.quantity}</Table.Td>
<Table.Td>{perUnit.toFixed(3)} t</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{(line.quantity * perUnit).toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}

View File

@@ -8,6 +8,7 @@ export * from "./BookingDetailHeader";
export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingContainerUnitsCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";

View File

@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
FileSignature,
MessageSquareWarning,
Play,
ShieldCheck,
@@ -211,29 +210,6 @@ const CANCEL_ACTION: BookingActionDef = {
inputPlaceholder: "Reason for cancellation…",
};
const VIEW_CONTRACT_ACTION: BookingActionDef = {
id: "viewContract",
label: "View contract",
shortLabel: "Contract",
description: "Open contract document and signatures",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: FileSignature,
};
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
id: "signContractStaff",
label: "Sign contract",
shortLabel: "Sign",
description: "Open contract page and apply staff counter-signature",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: FileSignature,
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 = {
@@ -340,22 +316,14 @@ export function getBookingActions(
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION];
actions = [CANCEL_ACTION];
break;
case "CONTRACT_READY":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
break;
case "SIGNED_CUSTOMER":
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break;
case "FULLY_EXECUTED":
actions = [
{
...VIEW_CONTRACT_ACTION,
label: "View executed contract",
primary: true,
},
];
// Contract view/sign/executed buttons intentionally removed from the
// booking-request page.
actions = [];
break;
case "AWAITING_DOCUMENTS":
case "DOCUMENTS_UNDER_REVIEW":

View File

@@ -1,7 +1,6 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FileSignature,
Layers,
LayoutGrid,
Milestone,
@@ -36,31 +35,19 @@ import {
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
BookingContainerUnitsCard,
ClearanceReviewSection,
ContractOrdersPanel,
type BookingFileView,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { BookingDetail } from "@/types/booking";
import { downloadBookingFile } from "@/services/files.service";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
// in the booking's Documents list.
const SIGNATURE_FILE_CODES = new Set([
"signature",
"signature_customer",
"signature_staff",
"contract",
]);
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() {
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<PageContainer>
@@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
const showContractButton = [
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
].includes(booking.status);
const showApprovalCard =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
@@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
<OverviewPanel booking={booking} row={row} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
@@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() {
)}
</Tabs>
) : (
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
<OverviewPanel booking={booking} row={row} />
)}
</Grid.Col>
@@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() {
View document clearance
</Button>
)}
{showContractButton && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View & sign contract
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
@@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() {
);
}
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
onDownload,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onDownload: (file: BookingFileView) => void;
}) {
return (
<Stack gap="lg">
@@ -351,15 +301,10 @@ function OverviewPanel({
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard 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

@@ -69,6 +69,17 @@ export interface BookingCompany {
website?: string | null;
}
/** One physical container under a line — its own number + verified gross mass. */
export interface BookingContainerUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
sortOrder?: number;
}
export interface BookingContainerLine {
id: string;
containerTypeId: string;
@@ -80,6 +91,8 @@ export interface BookingContainerLine {
label?: string;
sizeFt?: number;
};
/** Per-physical-container rows (number + weight). Empty when not captured. */
units?: BookingContainerUnit[];
}
export interface BookingApprovalStep {

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<

View File

@@ -356,6 +356,9 @@ export interface ContractClearanceView {
/** Export post-booking clearance finalized after transit permit upload. */
exportClearanceFinalized?: boolean;
linkedBookingId?: string | null;
/** Reference + status of the GL-created shipment booking, once it exists. */
linkedBookingReference?: string | null;
linkedBookingStatus?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -577,6 +580,12 @@ export interface IContract extends BaseEntity {
status: ContractStatus;
clearanceStatus: ContractClearanceStatus;
clearanceCycleNumber: number;
/**
* Latest clearance cycle's current phase (list responses only). Lets list
* consumers show step-accurate customer actions without fetching the full
* clearance view per contract.
*/
clearancePhase?: ContractDocPhase | string | null;
pricingBreakdown?: ContractPricingBreakdown | null;
pricingDisplayMode?: "UNIT_RATES";