mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
feat(bookings): add agent-driven clearance flow with forwarder/transit-agent panels and BookingClearedByAgent migration
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user