feat(bookings): add agent-driven clearance flow with forwarder/transit-agent panels and BookingClearedByAgent migration

This commit is contained in:
marshal
2026-09-08 08:49:33 +00:00
parent 850e0753d2
commit 7af3ded7d7
14 changed files with 1716 additions and 57 deletions

View File

@@ -23,6 +23,7 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
ArrowRight,
Building2,
@@ -36,6 +37,7 @@ import {
History,
MessageSquare,
PackageCheck,
Share2,
ShipWheel,
UserCheck,
} from "lucide-react";
@@ -52,6 +54,12 @@ import {
} from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import {
ExchangeDocumentsPanel,
IncidentsPanel,
WorkflowDocumentsPanel,
} from "./ForwarderBookingTabs";
import { ForwarderClearancePanel } from "./ForwarderClearancePanel";
import { ForwarderDocumentReview } from "./ForwarderDocumentReview";
const LIST_PATH = "/forwarder/assigned-bookings";
@@ -190,6 +198,9 @@ export default function AssignedBookingDetailPage() {
void bookingQuery.refetch();
void clearanceQuery.refetch();
};
const workflowFileCount = (clearance?.workflowFiles ?? []).filter(
(f) => f.file,
).length;
const kpis = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
@@ -344,9 +355,28 @@ export default function AssignedBookingDetailPage() {
<Tabs.Tab value="clearance" leftSection={<ClipboardList size={14} />}>
Clearance
</Tabs.Tab>
<Tabs.Tab
value="documents"
leftSection={<FileText size={14} />}
rightSection={
workflowFileCount > 0 ? (
<Badge size="xs" variant="light" color="edr-green" circle>
{workflowFileCount}
</Badge>
) : undefined
}
>
Documents
</Tabs.Tab>
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="clearance">
@@ -397,8 +427,15 @@ export default function AssignedBookingDetailPage() {
/>
) : null}
<DjiboutiAgentCard assignment={assignment} />
{assignedBookingsUnlocked && booking && clearance?.phase ? (
<ForwarderClearancePanel
booking={booking}
clearance={clearance}
onChanged={refresh}
/>
) : null}
<BookingFactsCard assignment={assignment} booking={booking ?? null} />
{assignedBookingsUnlocked && clearance ? (
{assignedBookingsUnlocked && clearance && !clearance.phase ? (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
@@ -431,13 +468,38 @@ export default function AssignedBookingDetailPage() {
</Grid>
</Tabs.Panel>
<Tabs.Panel value="documents">
{assignedBookingsUnlocked ? (
<WorkflowDocumentsPanel
clearance={clearance}
loading={clearanceQuery.isPending}
/>
) : (
<LockedNote what="The documents" />
)}
</Tabs.Panel>
<Tabs.Panel value="exchange">
{assignedBookingsUnlocked && bookingId ? (
<ExchangeDocumentsPanel bookingId={bookingId} />
) : (
<LockedNote what="The document exchange" />
)}
</Tabs.Panel>
<Tabs.Panel value="history">
{assignedBookingsUnlocked && bookingId ? (
<HistoryPanel bookingId={bookingId} />
) : (
<Alert color="yellow" variant="light" radius="md">
The clearance history opens once your role is approved.
</Alert>
<LockedNote what="The clearance history" />
)}
</Tabs.Panel>
<Tabs.Panel value="incidents">
{assignedBookingsUnlocked && bookingId ? (
<IncidentsPanel bookingId={bookingId} />
) : (
<LockedNote what="Incident reports" />
)}
</Tabs.Panel>
</Tabs>
@@ -446,6 +508,14 @@ export default function AssignedBookingDetailPage() {
);
}
function LockedNote({ what }: { what: string }) {
return (
<Alert color="yellow" variant="light" radius="md">
{what} open once your transit agent role is approved.
</Alert>
);
}
function BackButton({ onClick }: { onClick: () => void }) {
return (
<div>

View File

@@ -0,0 +1,461 @@
import {
Alert,
Badge,
Box,
Button,
Card,
Checkbox,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Download,
Eye,
EyeOff,
FileText,
Share2,
Upload,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useFileViewer } from "@/hooks/useFileViewer";
import { ClearanceWorkflowFilesPanel } from "@/pages/transit-agent/ClearanceWorkflowFilesPanel";
import { downloadStoredFile, fetchViewableFile } from "@/services/files.service";
import { transitAssignmentsService } from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
const formatDateTime = (value?: string | null): string =>
value ? new Date(value).toLocaleString() : "—";
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
}
const prettyStatus = (s?: string | null) =>
(s ?? "")
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color: string }> = {
ET: { label: "GL Ethiopia", color: "edr-green" },
DJ: { label: "GL Djibouti", color: "blue" },
TRANSIT: { label: "Agent", color: "grape" },
};
function EmptyPanel({ children }: { children: React.ReactNode }) {
return (
<Paper withBorder radius="md" p="lg" style={{ borderStyle: "dashed" }}>
<Group gap={10} wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="gray" radius="md" size={32}>
<FileText size={15} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{children}
</Text>
</Group>
</Paper>
);
}
/**
* Every customs workflow document on the booking, grouped by step — the
* customer's paperwork, the forwarder's declarations and permits, and the
* Djibouti agent's DO/RO/T1 — the GL page's "Documents" tab.
*/
export function WorkflowDocumentsPanel({
clearance,
loading,
}: {
clearance: Freight.ClearanceView | undefined;
loading: boolean;
}) {
const { view, viewer } = useFileViewer();
const files = (clearance?.workflowFiles ?? []).filter((f) => f.file);
if (loading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading documents</Text>
</Group>
);
}
return (
<>
{files.length > 0 ? (
<ClearanceWorkflowFilesPanel
files={clearance?.workflowFiles ?? []}
title="Customs documents (all steps)"
onView={view}
onDownload={(f) => void downloadStoredFile(f.id, f.name)}
/>
) : (
<EmptyPanel>
No customs workflow documents uploaded yet. Your declarations and
permits, and the Djibouti agent's DO/RO/T1 uploads, appear here.
</EmptyPanel>
)}
{viewer}
</>
);
}
/**
* The document exchange thread on the booking: what both Global Logistics
* desks and the assigned agents shared, plus sharing a document from here.
* Same thread the GL page and the Djibouti agent's page show.
*/
export function ExchangeDocumentsPanel({ bookingId }: { bookingId: string }) {
const { view, viewer } = useFileViewer();
const [shareOpen, setShareOpen] = useState(false);
const exchangeQuery = useQuery({
queryKey: ["forwarder-exchange", bookingId],
queryFn: () => transitAssignmentsService.glExchange(bookingId),
});
const docs = exchangeQuery.data ?? [];
const stats = {
et: docs.filter((d) => d.side === "ET").length,
dj: docs.filter((d) => d.side === "DJ").length,
transit: docs.filter((d) => d.side === "TRANSIT").length,
shared: docs.filter((d) => d.visibleToCustomer).length,
};
return (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
<Share2 size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={16}>
Document exchange
</Text>
<Text size="xs" c="dimmed">
Documents shared between Global Logistics, you and the Djibouti
agent for this shipment. Anything you share here is visible to
them immediately.
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => setShareOpen(true)}
>
Share document
</Button>
</Group>
{docs.length > 0 ? (
<Group gap={8} mt="md">
<Badge variant="light" color="edr-green" radius="sm" tt="none">
{stats.et} from GL Ethiopia
</Badge>
<Badge variant="light" color="blue" radius="sm" tt="none">
{stats.dj} from GL Djibouti
</Badge>
<Badge variant="light" color="grape" radius="sm" tt="none">
{stats.transit} from agents
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="none">
{stats.shared} visible to customer
</Badge>
</Group>
) : null}
</Paper>
{exchangeQuery.isPending ? (
<Group justify="center" py={40} gap={10}>
<Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed">
Loading shared documents
</Text>
</Group>
) : exchangeQuery.isError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
Could not load the shared documents.
</Alert>
) : docs.length === 0 ? (
<EmptyPanel>
Nothing shared yet. Scans, correspondence and corrected forms posted
by any party appear here.
</EmptyPanel>
) : (
<Stack gap={8}>
{docs.map((doc) => {
const side = SIDES[doc.side];
const canPreview = isViewable({ name: doc.file.name, url: "" });
return (
<Paper key={doc.id} withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<Group gap={12} wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color={side.color} radius="md" size={40}>
<FileText size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={700} truncate>
{doc.title}
</Text>
<Badge size="xs" variant="light" color={side.color} radius="sm" tt="none">
{side.label}
</Badge>
<Badge
size="xs"
variant="light"
color={doc.visibleToCustomer ? "teal" : "gray"}
radius="sm"
tt="none"
leftSection={doc.visibleToCustomer ? <Eye size={11} /> : <EyeOff size={11} />}
>
{doc.visibleToCustomer ? "Visible to customer" : "Agents & GL only"}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4} truncate>
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} · {formatDateTime(doc.uploadedAt)}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file.id, doc.file.name).then(view)
}
>
View
</Button>
</Tooltip>
) : null}
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() => void downloadStoredFile(doc.file.id, doc.file.name)}
>
Download
</Button>
</Tooltip>
</Group>
</Group>
</Paper>
);
})}
</Stack>
)}
<ShareExchangeModal
opened={shareOpen}
bookingId={bookingId}
onClose={() => setShareOpen(false)}
onShared={() => void exchangeQuery.refetch()}
/>
{viewer}
</Stack>
);
}
function ShareExchangeModal({
opened,
bookingId,
onClose,
onShared,
}: {
opened: boolean;
bookingId: string;
onClose: () => void;
onShared: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [visibleToCustomer, setVisibleToCustomer] = useState(false);
const close = () => {
setFile(null);
setTitle("");
setVisibleToCustomer(false);
onClose();
};
const submit = useMutation({
mutationFn: () =>
transitAssignmentsService.shareExchangeDocument(bookingId, {
file: file!,
title,
visibleToCustomer,
}),
onSuccess: () => {
toast.success("Document shared");
onShared();
close();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not share document"),
});
return (
<Modal
opened={opened}
onClose={close}
radius="md"
size="md"
title={
<Group gap={8}>
<Share2 size={18} />
<Text fw={700}>Share a document</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Global Logistics and the Djibouti agent see this immediately. Only you
can edit or remove what you post.
</Text>
<TextInput
label="Title"
placeholder="What is this document?"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
withAsterisk
/>
<Stack gap={6}>
<Text fz={13} fw={600}>
File
</Text>
<input
type="file"
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
style={{
border: "1px dashed var(--mantine-color-gray-4)",
borderRadius: 8,
padding: 10,
fontSize: 12.5,
background: "var(--mantine-color-gray-0)",
}}
/>
{file ? (
<Text fz={11.5} c="dimmed">
{file.name} · {formatBytes(file.size)}
</Text>
) : null}
</Stack>
<Checkbox
label="Also make this visible to the customer"
description="Off by default — clearance paperwork usually stays between the agents and the desks."
checked={visibleToCustomer}
onChange={(e) => setVisibleToCustomer(e.currentTarget.checked)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={submit.isPending}
disabled={!file || title.trim().length === 0}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
Share
</Button>
</Group>
</Stack>
</Modal>
);
}
/** Cargo exception reports logged against the shipment — read-only. */
export function IncidentsPanel({ bookingId }: { bookingId: string }) {
const incidentsQuery = useQuery({
queryKey: ["forwarder-incidents", bookingId],
queryFn: () => transitAssignmentsService.incidents(bookingId),
});
const incidents = incidentsQuery.data ?? [];
return (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="sm">
<ThemeIcon variant="light" color="red" radius="md" size={32}>
<AlertTriangle size={16} />
</ThemeIcon>
<Box>
<Text fw={700} fz={15}>
Incident reports
</Text>
<Text size="xs" c="dimmed">
Container or seal issues found during handling. Logged by the
operations desks read-only here.
</Text>
</Box>
</Group>
{incidentsQuery.isPending ? (
<Group py="lg" justify="center">
<Loader size="sm" color="edr-green" />
</Group>
) : incidentsQuery.isError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
Incident reports are not available for this shipment.
</Alert>
) : incidents.length > 0 ? (
<Stack gap="xs">
{incidents.map((inc: Freight.IClearanceIncident) => (
<Card key={inc.id} withBorder radius="md" p="sm">
<Group gap={10} wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="red" radius="md" size={32}>
<AlertTriangle size={15} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={13} fw={600}>
{prettyStatus(inc.incidentType)}
</Text>
{inc.description ? (
<Text fz={12.5} c="edr-text" mt={2}>
{inc.description}
</Text>
) : null}
<Text fz={11} c="edr-muted" mt={3}>
{formatDateTime(inc.createdAt)}
</Text>
</Box>
</Group>
</Card>
))}
</Stack>
) : (
<Group gap={8}>
<CheckCircle2 size={15} className="text-edr-muted" />
<Text fz={13} c="dimmed">
No incidents reported for this shipment.
</Text>
</Group>
)}
</Card>
);
}

View File

@@ -0,0 +1,637 @@
import {
Alert,
Box,
Button,
Card,
Group,
NumberInput,
Progress,
Select,
Stack,
Stepper,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowRight, CheckCircle2, Ship, Upload } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { PortalMultiFileDropzone } from "@/components/contracts/PortalMultiFileDropzone";
import { api } from "@/services/api";
import { transitAssignmentsService } from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import { isReleaseOrderFileCode } from "@edr/types";
/** Who does the step: the forwarder (this page), the Djibouti agent it named, the customer, or the system. */
type Owner = "forwarder" | "djibouti" | "customer" | "system";
interface Step {
key: string;
label: string;
description: string;
owner: Owner;
done: boolean;
}
type MilestoneRow = { milestoneCode?: string | null; status?: string | null };
function isMilestoneDone(ms: MilestoneRow[] | undefined, code: string): boolean {
const m = ms?.find((x) => x.milestoneCode === code);
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
const formatStamp = (value: string): string => new Date(value).toLocaleString();
function trainLegDescription(train: Freight.ClearanceView["train"] | undefined): string {
if (train?.arrivedAt) return `Arrived ${formatStamp(train.arrivedAt)}`;
if (train?.departedAt) return `Departed ${formatStamp(train.departedAt)} · in transit`;
return "Departure and arrival";
}
const OWNER_LABEL: Record<Owner, string> = {
forwarder: "You",
djibouti: "Djibouti agent",
customer: "Customer",
system: "Operations",
};
function apiMessage(e: unknown, fallback: string): string {
const err = e as { response?: { data?: { message?: string | string[] } }; message?: string };
const m = err.response?.data?.message;
const text = Array.isArray(m) ? m.join(", ") : m;
return text || err.message || fallback;
}
/**
* The phased clearance wizard for a booking the customer handed to this
* forwarder — the GL desk's clearance action panel, with the forwarder in GL
* Ethiopia's place and the Djibouti agent it named in GL Djibouti's.
*
* Differences from the GL flow, by design: no "request transit assignee"
* step (the forwarder names the Djibouti agent from the card beside this),
* no duty & tax rounds (the forwarder settles duty with customs off the
* platform), and "Create booking" is the customer's — once pre-clearance is
* done the customer completes the booking itself.
*
* Every step's `done` reads off the clearance payload so the wizard can never
* claim a step the server does not consider complete. The action for the
* current step, when it is the forwarder's, renders under the stepper.
*/
export function ForwarderClearancePanel({
booking,
clearance,
onChanged,
}: {
booking: Freight.IBooking;
clearance: Freight.ClearanceView;
onChanged?: () => void;
}) {
const queryClient = useQueryClient();
const bookingId = booking.id;
const isImport = booking.tradeDirection === "IMPORT";
const ms = clearance.milestones;
const done = (code: string) => isMilestoneDone(ms, code);
const workflowFiles = clearance.workflowFiles ?? [];
const hasRo = workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file);
const hasT1Docs = workflowFiles.some(
(f) => /t1/i.test(f.code) && !/djibouti/i.test(f.code) && f.file,
);
const bookingCreated =
Number(booking.totalAmount ?? 0) > 0 ||
done("FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.t1);
const draftUploaded = done("DRAFT_DECLARATION_UPLOADED");
const draftAccepted = done("DRAFT_DECLARATION_ACCEPTED");
const steps: Step[] = isImport
? [
{
key: "docs",
label: "Customer documents",
description: "Review and approve in the panel on the left",
owner: "forwarder",
done: done("DOCUMENTS_APPROVED") || Boolean(clearance.allApproved),
},
{
key: "draft",
label: "Draft declaration",
description: draftUploaded
? draftAccepted
? "Customer accepted the estimated price"
: "Sent — waiting for the customer to accept"
: "Send the customer a draft declaration with an estimated price",
owner: "forwarder",
done: draftAccepted || done("DECLARED"),
},
{
key: "declaration",
label: "Customs declaration",
description: "Upload the declaration documents",
owner: "forwarder",
done: done("DECLARED"),
},
{
key: "permit",
label: "Transit permit",
description: "Upload the transit permit documents",
owner: "forwarder",
done: done("TRANSIT_PERMIT_UPLOADED"),
},
{
key: "finalize",
label: "Finalize pre-clearance",
description: "Hand off to the Djibouti transit agent",
owner: "forwarder",
done: Boolean(clearance.preClearanceFinalized),
},
{
key: "do",
label: "Delivery Order",
description: "The Djibouti agent uploads the DO",
owner: "djibouti",
done: done("DO_COLLECTED"),
},
{
key: "create",
label: "Create booking",
description: "The customer completes the booking",
owner: "customer",
done: bookingCreated,
},
{
key: "payment",
label: "Freight payment",
description: "Customer pays the train and service charges",
owner: "customer",
done: done("FREIGHT_PAYMENT_SETTLED"),
},
{
key: "gatepass",
label: "Gate pass",
description: "Secured on the train schedule after payment and wagon allocation",
owner: "system",
done: Boolean(clearance.gatepassGranted),
},
{
key: "offload",
label: "Offload",
description: "Cargo comes off the train at its destination",
owner: "system",
done: Boolean(clearance.offloaded ?? clearance.offload?.offloaded),
},
{
key: "t1docs",
label: "T1 transport documents",
description: "The Djibouti agent uploads after the train departs",
owner: "djibouti",
done: hasT1Docs || Boolean(clearance.t1Closed),
},
{
key: "t1close",
label: "Close T1",
description: "Close once the train has arrived",
owner: "forwarder",
done: Boolean(clearance.t1Closed),
},
{
key: "risk",
label: "Customs risk",
description: clearance.riskLevel
? `Assigned: ${clearance.riskLevel}`
: "Assign Green / Yellow / Red",
owner: "forwarder",
done: Boolean(clearance.riskLevel),
},
{
key: "release",
label: "Import release",
description: "Upload the release document",
owner: "forwarder",
done: Boolean(clearance.importReleaseGranted),
},
]
: [
{
key: "docs",
label: "Customer documents",
description: "Review and approve in the panel on the left",
owner: "forwarder",
done: done("DOCUMENTS_APPROVED") || Boolean(clearance.allApproved),
},
{
key: "ro",
label: "Release Order",
description: "The Djibouti agent uploads the RO with the vessel date",
owner: "djibouti",
done: done("RELEASE_ORDER_SECURED") || hasRo,
},
{
key: "declaration",
label: "Customs declaration",
description: done("RELEASE_ORDER_SECURED")
? "Upload the declaration — releases the export"
: "Upload the declaration; the export is released once the Release Order is in too",
owner: "forwarder",
done: done("DECLARED"),
},
{
key: "create",
label: "Create booking",
description: "The customer completes the booking",
owner: "customer",
done: bookingCreated,
},
{
key: "payment",
label: "Payment & wagon allocation",
description: "Customer pays; operations allocates wagons",
owner: "customer",
done:
done("FREIGHT_PAYMENT_SETTLED") &&
(done("WAGON_ALLOCATED") || Boolean(clearance.train?.wagonAllocated)),
},
{
key: "transport",
label: "Transport document",
description: "Upload after wagon allocation",
owner: "forwarder",
done: done("EXPORT_TRANSPORT_ISSUED"),
},
{
key: "train",
label: "Train to Djibouti",
description: trainLegDescription(clearance.train),
owner: "system",
done: Boolean(clearance.train?.arrivedAt),
},
{
key: "t1close",
label: "Accept T1",
description: "The Djibouti agent closes once the train arrives",
owner: "djibouti",
done: Boolean(clearance.t1Closed),
},
{
key: "gatepass",
label: "Gate pass",
description: "Secured on the train schedule after arrival",
owner: "system",
done: Boolean(clearance.gatepassGranted),
},
{
key: "offload",
label: "Offload",
description: "Cargo comes off the train at its destination",
owner: "system",
done: Boolean(clearance.offloaded ?? clearance.offload?.offloaded),
},
];
const firstPending = steps.findIndex((s) => !s.done);
const activeStep = firstPending === -1 ? steps.length : firstPending;
const percent = Math.round((activeStep / steps.length) * 100);
const current = steps[activeStep] ?? null;
const refresh = () => {
void queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: bookingId }),
});
void queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: bookingId }),
});
void queryClient.invalidateQueries({
queryKey: ["forwarder-clearance-history", bookingId],
});
onChanged?.();
};
return (
<Card withBorder shadow="sm" radius="lg" p={0} style={{ overflow: "hidden" }}>
<Group
justify="space-between"
wrap="nowrap"
px={18}
py={12}
style={{ borderBottom: "1px solid var(--mantine-color-edr-divider-6)" }}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<Ship size={16} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={14} c="edr-text">
{isImport ? "Import clearance" : "Export clearance"}
</Text>
<Text fz={11.5} c="dimmed">
Step {Math.min(activeStep + 1, steps.length)} of {steps.length}
{current ? ` · ${current.label}` : " · complete"}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Progress value={percent} color="edr-green" radius="xl" size={6} w={110} />
<Text fz={11.5} c="#67788A" fw={600}>
{percent}%
</Text>
</Group>
</Group>
{current ? (
<Group
gap={10}
wrap="nowrap"
px={18}
py={12}
style={{ background: "#E9F1FC", borderBottom: "1px solid #EFF3F7" }}
>
<ArrowRight size={15} color="#1D6FD1" style={{ flexShrink: 0 }} />
<Text fz={10.5} fw={700} lts="0.4px" c="#1D6FD1" style={{ flexShrink: 0 }}>
{OWNER_LABEL[current.owner].toUpperCase()}
</Text>
<Text fz={11.5} fw={600} c="edr-text" style={{ minWidth: 0 }}>
{current.description}
</Text>
</Group>
) : null}
<Box p="md">
{clearance.roHoldReason ? (
<Alert color="red" variant="light" mb="sm" title="RO amendment hold">
{clearance.roHoldReason}
</Alert>
) : null}
<Stepper
active={activeStep}
orientation="vertical"
size="sm"
iconSize={26}
allowNextStepsSelect={false}
mb="md"
>
{steps.map((s) => (
<Stepper.Step
key={s.key}
label={s.label}
description={
s.owner === "forwarder" ? s.description : `${OWNER_LABEL[s.owner]} · ${s.description}`
}
icon={s.done ? <CheckCircle2 size={14} /> : undefined}
/>
))}
</Stepper>
{current?.owner === "forwarder" ? (
<StepAction
step={current.key}
bookingId={bookingId}
isImport={isImport}
draftUploaded={draftUploaded}
onDone={refresh}
/>
) : current ? (
<Alert color="gray" variant="light" radius="md">
Waiting on {OWNER_LABEL[current.owner].toLowerCase()}: {current.description}.
</Alert>
) : (
<Alert color="teal" variant="light" radius="md" icon={<CheckCircle2 size={16} />}>
Every step is complete.
</Alert>
)}
</Box>
</Card>
);
}
/** The control for the forwarder's current step. */
function StepAction({
step,
bookingId,
isImport,
draftUploaded,
onDone,
}: {
step: string;
bookingId: string;
isImport: boolean;
draftUploaded: boolean;
onDone: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [price, setPrice] = useState<number | string>("");
const [currency, setCurrency] = useState<string>("ETB");
const [risk, setRisk] = useState<"GREEN" | "YELLOW" | "RED" | null>(null);
const [note, setNote] = useState("");
const run = useMutation({
mutationFn: async () => {
switch (step) {
case "draft":
await transitAssignmentsService.uploadDraftDeclaration(
bookingId,
files,
Number(price),
currency,
);
return "Draft declaration sent to the customer";
case "declaration":
// On export the API releases the export itself once both the
// declaration and the Release Order are on file.
await transitAssignmentsService.uploadDeclaration(bookingId, files);
return isImport ? "Declaration uploaded" : "Declaration uploaded — the export is released once the Release Order is in too";
case "permit":
await transitAssignmentsService.uploadTransitPermit(bookingId, files);
return "Transit permit uploaded";
case "finalize":
await transitAssignmentsService.finalizePreClearance(bookingId);
return "Pre-clearance finalized — handed to the Djibouti agent";
case "transport":
await transitAssignmentsService.uploadTransportDocument(bookingId, files);
return "Transport document uploaded";
case "t1close":
await transitAssignmentsService.closeT1(bookingId);
return "T1 closed";
case "risk":
await transitAssignmentsService.assignRisk(bookingId, risk!, note);
return "Customs risk assigned";
case "release":
await transitAssignmentsService.uploadImportRelease(bookingId, files);
return "Import release uploaded";
default:
return "Done";
}
},
onSuccess: (message) => {
toast.success(message);
setFiles([]);
setNote("");
setRisk(null);
onDone();
},
onError: (e: unknown) => toast.error(apiMessage(e, "The step could not be completed")),
});
const skipDraft = useMutation({
mutationFn: () => transitAssignmentsService.skipDraftDeclaration(bookingId),
onSuccess: () => {
toast.success("Draft declaration skipped — file the declaration directly");
onDone();
},
onError: (e: unknown) => toast.error(apiMessage(e, "Could not skip the draft")),
});
if (step === "docs") {
return (
<Alert color="blue" variant="light" radius="md">
Approve every required customer document in the review panel to move on.
</Alert>
);
}
if (step === "draft") {
if (draftUploaded) {
return (
<Alert color="blue" variant="light" radius="md">
The draft declaration is with the customer. The next step unlocks when
they accept it, or when they send it back for changes.
</Alert>
);
}
return (
<Stack gap="sm">
<PortalMultiFileDropzone
label="Draft declaration documents"
description="The estimate the customer reviews before you file the real declaration."
files={files}
onChange={setFiles}
/>
<Group grow align="flex-end">
<NumberInput
label="Estimated price"
placeholder="0.00"
min={0}
decimalScale={2}
thousandSeparator=","
value={price}
onChange={setPrice}
radius="md"
/>
<Select
label="Currency"
data={["ETB", "USD", "DJF"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
allowDeselect={false}
radius="md"
/>
</Group>
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
radius="md"
loading={skipDraft.isPending}
onClick={() => skipDraft.mutate()}
>
Skip the draft
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={files.length === 0 || !(Number(price) >= 0) || price === ""}
loading={run.isPending}
onClick={() => run.mutate()}
>
Send draft declaration
</Button>
</Group>
</Stack>
);
}
if (step === "finalize" || step === "t1close") {
return (
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
loading={run.isPending}
onClick={() => run.mutate()}
>
{step === "finalize" ? "Finalize pre-clearance" : "Close T1"}
</Button>
</Group>
);
}
if (step === "risk") {
return (
<Stack gap="sm">
<Select
label="Customs risk level"
placeholder="Pick a level"
data={[
{ value: "GREEN", label: "Green" },
{ value: "YELLOW", label: "Yellow" },
{ value: "RED", label: "Red" },
]}
value={risk}
onChange={(v) => setRisk((v as "GREEN" | "YELLOW" | "RED" | null) ?? null)}
radius="md"
/>
<Textarea
label="Note (optional)"
autosize
minRows={2}
radius="md"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!risk}
loading={run.isPending}
onClick={() => run.mutate()}
>
Assign risk
</Button>
</Group>
</Stack>
);
}
// Every remaining forwarder step is a document upload.
const uploadLabel: Record<string, string> = {
declaration: "Customs declaration documents",
permit: "Transit permit documents",
transport: "Transport documents",
release: "Import release document",
};
return (
<Stack gap="sm">
<PortalMultiFileDropzone
label={uploadLabel[step] ?? "Documents"}
files={files}
onChange={setFiles}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={files.length === 0}
loading={run.isPending}
onClick={() => run.mutate()}
>
Upload
</Button>
</Group>
</Stack>
);
}

View File

@@ -131,8 +131,12 @@ export function ForwarderDocumentReview({
const { clearance, customerDocs, canUpload, pending, adHoc, status } = flow;
const docNoun = bookingDocNoun(booking);
const reviewOpen = clearance.documentsOpen ?? canUpload;
// A phased (agent-cleared) booking advances through the clearance wizard —
// approving the last document moves it on by itself, and the server refuses
// the plain finalize. Only a non-phased booking finalizes from here.
const phased = Boolean(clearance.phase);
const canFinalize =
status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
!phased && status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
const finalized = [
"CLEARANCE_READY",
"OPERATION_REQUEST_PENDING",
@@ -147,6 +151,11 @@ export function ForwarderDocumentReview({
Clearance is finalized. The customer completes the booking from
here; documents stay open for additions until the shipment is paid.
</Alert>
) : phased && clearance.allApproved ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
Every required document is approved. Continue with the clearance
steps in the panel on the right.
</Alert>
) : status === "AWAITING_DOCUMENTS" ? (
<Alert color="yellow" radius="md" icon={<Upload size={18} />}>
Waiting for the {docNoun}. Upload them on the customer's behalf
@@ -440,7 +449,7 @@ export function ForwarderDocumentReview({
Submit documents
</Button>
) : null}
{!finalized ? (
{!finalized && !phased ? (
<Button
color="edr-green"
radius="md"

View File

@@ -81,6 +81,18 @@ export function TransitClearanceActionPanel({
const queryClient = useQueryClient();
const [amendOpen, setAmendOpen] = useState(false);
// Export, agent-cleared booking: the Djibouti agent accepts the T1 in GL
// Djibouti's place once the train has arrived. Elsewhere the desk does it.
const closeT1 = useMutation({
mutationFn: () => transitAssignmentsService.closeT1(bookingId),
onSuccess: () => {
toast.success("T1 accepted");
refresh();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not accept the T1"),
});
const isImport = tradeDirection === "IMPORT";
const workflowFiles = clearance?.workflowFiles ?? [];
const hasRo = workflowFiles.some(
@@ -108,19 +120,26 @@ export function TransitClearanceActionPanel({
const done = (code: string) => isMilestoneDone(ms, code);
const bookingDone = (code: string) => isMilestoneDone(ms, code);
const dutyRequired = clearance?.dutyRequired ?? false;
// A booking a clearing agent (forwarder) handles: it reviews the documents
// and files the declaration in GL Ethiopia's place, the customer creates the
// booking itself, and the RO does not wait for the declaration.
const byAgent = Boolean(clearance?.clearedByAgent);
const etDesk = byAgent ? "the clearing agent" : "GL Ethiopia";
const steps: WizardStep[] = isImport
? [
{
label: "Customer documents",
description: "Reviewed and approved by GL Ethiopia",
description: `Reviewed and approved by ${etDesk}`,
done: done("DOCUMENTS_APPROVED"),
},
{
label: "Request transit assignee",
description: clearance?.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "GL Djibouti names the officer handling this shipment",
: byAgent
? "The clearing agent names the officer handling this shipment"
: "GL Djibouti names the officer handling this shipment",
done: Boolean(clearance?.transitAssignee?.name),
},
{
@@ -130,7 +149,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Customs declaration",
description: "GL Ethiopia uploads declaration documents",
description: `${byAgent ? "The clearing agent" : "GL Ethiopia"} uploads declaration documents`,
done: done("DECLARED"),
},
{
@@ -151,7 +170,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Finalize pre-clearance",
description: "GL Ethiopia hands off to GL Djibouti",
description: byAgent ? "The clearing agent hands off to you" : "GL Ethiopia hands off to GL Djibouti",
done: Boolean(clearance?.preClearanceFinalized),
},
{
@@ -161,7 +180,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Create booking",
description: "GL Ethiopia books for the customer",
description: byAgent ? "The customer completes the booking" : "GL Ethiopia books for the customer",
done: Boolean(clearance?.t1) || bookingDone("FREIGHT_PAYMENT_SETTLED"),
},
{
@@ -178,19 +197,21 @@ export function TransitClearanceActionPanel({
: [
{
label: "Customer documents",
description: "Reviewed and approved by GL Ethiopia",
description: `Reviewed and approved by ${etDesk}`,
done: done("DOCUMENTS_APPROVED"),
},
{
label: "Request transit assignee",
description: clearance?.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "GL Djibouti names the officer handling this shipment",
: byAgent
? "The clearing agent names the officer handling this shipment"
: "GL Djibouti names the officer handling this shipment",
done: Boolean(clearance?.transitAssignee?.name),
},
{
label: "Customs declaration",
description: "GL Ethiopia uploads — releases the export",
description: `${byAgent ? "The clearing agent" : "GL Ethiopia"} uploads — releases the export`,
done: done("DECLARED"),
},
{
@@ -200,7 +221,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Create booking",
description: "GL Ethiopia books for the customer",
description: byAgent ? "The customer completes the booking" : "GL Ethiopia books for the customer",
done: bookingDone("FREIGHT_PAYMENT_SETTLED") || Boolean(clearance?.t1),
},
{
@@ -213,7 +234,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Transport document",
description: "GL Ethiopia uploads after wagon allocation",
description: `${byAgent ? "The clearing agent" : "GL Ethiopia"} uploads after wagon allocation`,
done: bookingDone("EXPORT_TRANSPORT_ISSUED"),
},
{
@@ -238,6 +259,17 @@ export function TransitClearanceActionPanel({
},
];
// On an agent-cleared export the RO goes in right after the customer
// documents, ahead of the declaration — the order the API enforces there.
if (!isImport && byAgent) {
const roIdx = steps.findIndex((s) => s.label === "Release Order");
const declIdx = steps.findIndex((s) => s.label === "Customs declaration");
if (roIdx > declIdx && declIdx >= 0) {
const [ro] = steps.splice(roIdx, 1);
steps.splice(declIdx, 0, ro!);
}
}
// The wizard sits on the FIRST step not yet done — matching the GL desk's
// "Step N of M", which counts the step being worked on, not the ones behind it.
const firstPending = steps.findIndex((s) => !s.done);
@@ -350,6 +382,19 @@ export function TransitClearanceActionPanel({
transit documents panel above the grid, where its timings are
shown; only the RO amendment request stays here. */}
<Stack gap={8}>
{!isImport &&
clearance?.train?.arrivedAt &&
!clearance?.t1Closed ? (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
loading={closeT1.isPending}
onClick={() => closeT1.mutate()}
>
Accept T1
</Button>
) : null}
{!isImport ? (
<Button
variant="subtle"

View File

@@ -465,13 +465,25 @@ function ReleaseOrderCard({
);
const hasRo = roFiles.length > 0;
// The customs declaration is the gate: the API refuses an RO before it.
// The customs declaration is the gate: the API refuses an RO before it
// except on a booking a clearing agent handles, where the RO may go in as
// soon as the customer's documents are approved, declaration or not.
const declaredMilestone = clearance.milestones?.find(
(m) => m.milestoneCode === "DECLARED",
);
const declared =
const docsApprovedMilestone = clearance.milestones?.find(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED",
);
const docsApproved =
docsApprovedMilestone?.status === "COMPLETED" ||
docsApprovedMilestone?.status === "SKIPPED" ||
Boolean(clearance.allApproved);
const declarationFiled =
declaredMilestone?.status === "COMPLETED" ||
declaredMilestone?.status === "SKIPPED";
const declared = clearance.clearedByAgent
? docsApproved
: declarationFiled;
const declaredAt =
declaredMilestone?.triggeredAt ??
latestEvent(history, "DECLARATION_UPLOADED")?.at ??
@@ -521,7 +533,7 @@ function ReleaseOrderCard({
tt="none"
leftSection={<Lock size={10} />}
>
Waiting for declaration
{clearance.clearedByAgent ? "Waiting for documents" : "Waiting for declaration"}
</Badge>
);
@@ -568,7 +580,15 @@ function ReleaseOrderCard({
icon={FileText}
label="Declaration uploaded"
value={declaredAt ? formatStamp(declaredAt) : declared ? "Done" : "Pending"}
hint={declared ? "By GL Ethiopia — RO unlocked" : "Unlocks the Release Order"}
hint={
clearance.clearedByAgent
? declarationFiled
? "Filed by the clearing agent"
: "The clearing agent files it — the RO does not wait for it"
: declared
? "By GL Ethiopia — RO unlocked"
: "Unlocks the Release Order"
}
tone={declared ? "green" : "muted"}
/>
<Stat
@@ -611,7 +631,9 @@ function ReleaseOrderCard({
<EmptyDocs icon={Ship}>
{declared
? "No Release Order on file yet. Upload the RO and confirm the vessel departure date."
: "The Release Order can be uploaded as soon as the customs declaration is on file."}
: clearance.clearedByAgent
? "The Release Order can be uploaded as soon as the customer documents are approved."
: "The Release Order can be uploaded as soon as the customs declaration is on file."}
</EmptyDocs>
)}
</Stack>

View File

@@ -425,6 +425,100 @@ export const transitAssignmentsService = {
return data.data ?? data;
},
// ── Phased workflow, GL Ethiopia's steps done by the clearing agent ──────
// On an agent-cleared booking the forwarder files what the GL Ethiopia desk
// would: draft and final declaration, transit permit, pre-clearance handoff,
// export release, transport document, T1 close, risk, import release. The
// API accepts these only from the agent assigned to that booking.
uploadDraftDeclaration: async (
bookingId: string,
files: File[],
price: number,
currency: string,
): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
form.append("price", String(price));
form.append("currency", currency);
await client.post(
`/api/bookings/${bookingId}/clearance/draft-declaration`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
skipDraftDeclaration: async (bookingId: string): Promise<void> => {
await client.post(
`/api/bookings/${bookingId}/clearance/draft-declaration/skip`,
);
},
uploadDeclaration: async (bookingId: string, files: File[]): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
await client.post(`/api/bookings/${bookingId}/clearance/declaration`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
},
uploadTransitPermit: async (bookingId: string, files: File[]): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
await client.post(
`/api/bookings/${bookingId}/clearance/transit-permit`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
finalizePreClearance: async (bookingId: string): Promise<void> => {
await client.post(
`/api/bookings/${bookingId}/clearance/finalize-pre-clearance`,
);
},
confirmExportRelease: async (bookingId: string): Promise<void> => {
await client.post(`/api/bookings/${bookingId}/clearance/export-release`);
},
uploadTransportDocument: async (
bookingId: string,
files: File[],
): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
await client.post(
`/api/contracts/bookings/${bookingId}/transport-document`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
closeT1: async (bookingId: string): Promise<void> => {
await client.post(`/api/contracts/bookings/${bookingId}/t1-close`);
},
assignRisk: async (
bookingId: string,
riskLevel: "GREEN" | "YELLOW" | "RED",
note?: string,
): Promise<void> => {
await client.post(`/api/contracts/bookings/${bookingId}/risk`, {
riskLevel,
...(note?.trim() ? { note: note.trim() } : {}),
});
},
/** Import release document; the field name is the milestone trigger. */
uploadImportRelease: async (bookingId: string, files: File[]): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("import_release", f);
await client.post(`/api/contracts/bookings/${bookingId}/documents`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
},
/** T1 transit documents (import); locked once GL Ethiopia closes the T1. */
uploadT1Documents: async (
bookingId: string,