changes on transit agent

This commit is contained in:
marshal
2026-09-08 06:58:14 +00:00
parent 92697386e4
commit 850e0753d2
14 changed files with 1528 additions and 115 deletions

View File

@@ -229,6 +229,8 @@ export const URL_CONSTANTS = {
TRANSIT_AGENTS_API: {
/** Active Ethiopian transit agents (id + name) a forwarder can register as. */
FORWARDER_OPTIONS: "/api/transit-agents/forwarder-options",
/** Active Djibouti transit agents (id + name) an assigned clearing agent can hand the transit leg to. */
DJIBOUTI_OPTIONS: "/api/transit-agents/djibouti-options",
},
PORTAL_CONTENT: {
PUBLIC: "/api/support-content",

View File

@@ -1,32 +1,58 @@
import {
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Grid,
Group,
Loader,
Paper,
RingProgress,
Select,
SimpleGrid,
Stack,
Tabs,
Text,
Textarea,
ThemeIcon,
Timeline,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Building2,
CheckCircle2,
CircleDot,
ClipboardList,
Clock,
Clock3,
FilePlus2,
FileText,
History,
MessageSquare,
PackageCheck,
ShipWheel,
UserCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate, useParams } from "react-router-dom";
import { bookingDocNounCapitalized } from "@/pages/bookings/clearance/bookingNextAction";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import {
transitAssignmentsService,
type TransitAssignment,
type TransitAssignmentStatus,
} from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import { AssignedBookingDocumentsLoader } from "./AssignedBookingDocuments";
import { ForwarderDocumentReview } from "./ForwarderDocumentReview";
const LIST_PATH = "/forwarder/assigned-bookings";
@@ -57,28 +83,73 @@ function formatDate(value?: string | null): string {
});
}
function formatDateTime(value: string): string {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleString();
}
function apiMessage(e: Error, fallback: string): string {
const data = (e as { response?: { data?: { message?: string | string[] } } })
.response?.data;
const message = Array.isArray(data?.message)
? data.message.join(", ")
: data?.message;
return message || e.message || fallback;
}
/**
* One booking a customer assigned to this forwarder, with the customer's
* import/export document grid on it.
* The clearing agent's working page for one assigned booking — the portal
* counterpart of the GL Ethiopia clearance page in the backoffice.
*
* The forwarder clears customs on the customer's behalf, so it uploads the
* booking's clearance documents through the same flow and endpoint the
* customer uses — the API admits the assigned agent to both. Both parties can
* upload; what the forwarder never does here is pick the shipment day, which
* stays the customer's decision (`uploadOnly`).
*
* Until the roster role is approved the API hides the booking, so the page
* shows the assignment's own facts and says why the documents are not there.
* Same shape as that page: header with the review state, a KPI strip over the
* customer's documents, the review grid on the left (approve, query, upload
* on the customer's behalf, finalize), and the side column with the request
* for more documents, the Djibouti transit officer, and the booking facts.
* The History tab is the clearance trail. Uploading, reviewing and assigning
* are locked until the roster role is approved, since the API hides the
* booking until then.
*/
export default function AssignedBookingDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { assignedBookingsUnlocked } = useAuth();
const assignmentQuery = useQuery({
queryKey: ["transit-assignments", "my", id],
queryFn: () => transitAssignmentsService.getById(id!),
enabled: Boolean(id),
});
const assignment = assignmentQuery.data;
const bookingId = assignment?.bookingId;
const bookingQuery = useQuery({
...api.bookings.get.queryOptions({ input: { id: bookingId ?? "" } }),
enabled: Boolean(bookingId) && assignedBookingsUnlocked,
});
const booking = bookingQuery.data;
const clearanceQuery = useQuery({
...api.bookings.getClearance.queryOptions({
input: { id: bookingId ?? "" },
}),
enabled: Boolean(bookingId) && assignedBookingsUnlocked,
});
const clearance = clearanceQuery.data;
const stats = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
const total = docs.length;
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
const awaitingReview = docs.filter(
(d) => d.file && d.reviewStatus !== "APPROVED",
).length;
return { total, approved, queried, pending, pct, awaitingReview };
}, [clearance]);
if (assignmentQuery.isPending) {
return (
@@ -108,81 +179,268 @@ export default function AssignedBookingDetailPage() {
}
const b = assignment.booking;
const reference = b?.reference ?? booking?.reference ?? "Assigned booking";
const direction = b?.tradeDirection ?? booking?.tradeDirection ?? null;
const statusMeta = STATUS_META[assignment.status];
const origin =
booking?.originYard?.label ?? booking?.originYard?.code ?? null;
const destination =
booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? null;
const refresh = () => {
void bookingQuery.refetch();
void clearanceQuery.refetch();
};
const kpis = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<Box p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap="lg">
<BackButton onClick={() => navigate(LIST_PATH)} />
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="sm" align="center">
<div className="flex size-10 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
<PackageCheck size={20} />
</div>
<Box>
<Title order={2}>{b?.reference ?? "Assigned booking"}</Title>
<Group gap={4} wrap="nowrap" align="center">
<Building2 size={12} className="shrink-0 text-edr-muted" />
<Text c="edr-muted" size="sm">
{assignment.customerName ?? "—"}
</Text>
</Group>
</Box>
</Group>
<Group gap="xs">
{b?.tradeDirection ? (
<Badge size="sm" variant="light" radius="sm" color="blue">
{prettyStatus(b.tradeDirection)}
</Badge>
) : null}
{b?.status ? (
<Badge size="sm" variant="light" radius="sm" color="gray">
{prettyStatus(b.status)}
</Badge>
) : null}
<Badge size="sm" variant="light" radius="sm" color={statusMeta.color}>
{statusMeta.label}
</Badge>
</Group>
</Group>
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="xl" wrap="wrap">
<Fact
icon={<Clock3 size={14} />}
label="Assigned"
value={formatDate(assignment.assignedAt)}
/>
<Fact
icon={<Clock3 size={14} />}
label="Started"
value={formatDate(assignment.startedAt)}
/>
<Fact
icon={<Clock3 size={14} />}
label="Finished"
value={formatDate(assignment.finishedAt)}
/>
</Group>
{assignment.note ? (
<Text fz={13} c="edr-text" mt="sm" style={{ whiteSpace: "pre-wrap" }}>
{assignment.note}
{/* ── Header ─────────────────────────────────────────────── */}
<Stack gap="sm">
<Group gap={6}>
<Anchor
fz={12.5}
c="edr-muted"
onClick={() => navigate(LIST_PATH)}
style={{ cursor: "pointer" }}
>
Assigned bookings
</Anchor>
<Text fz={12.5} c="edr-muted">
/
</Text>
) : null}
</Card>
<Text fz={12.5} c="edr-text">
{reference}
</Text>
</Group>
<Card withBorder shadow="sm" radius="lg" p="md">
<Title order={4} mb="md">
{b
? bookingDocNounCapitalized({
customsClearingEnabled: false,
tradeDirection: b.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT",
})
: "Documents"}
</Title>
<AssignedBookingDocumentsLoader assignment={assignment} />
</Card>
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Group gap="sm" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Button
variant="subtle"
color="gray"
px={8}
onClick={() => navigate(LIST_PATH)}
aria-label="Go back"
>
<ArrowLeft size={18} />
</Button>
<div style={{ minWidth: 0, maxWidth: 720 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} className="truncate" style={{ minWidth: 0 }}>
{reference}
</Title>
{direction ? (
<Badge
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{prettyStatus(direction)}
</Badge>
) : null}
{booking?.status ? (
<Badge variant="light" color="gray" radius="sm">
{prettyStatus(booking.status)}
</Badge>
) : null}
<Badge variant="light" color={statusMeta.color} radius="sm">
{statusMeta.label}
</Badge>
{clearance ? (
stats.awaitingReview > 0 ? (
<Badge
variant="filled"
color="orange"
radius="sm"
leftSection={<Clock size={13} />}
>
{stats.awaitingReview} needs approval
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
) : null}
</Group>
<Group gap={10} wrap="wrap" mt={4}>
<Group gap={5} wrap="nowrap">
<Building2 size={12} className="shrink-0 text-edr-muted" />
<Text c="dimmed" size="sm" className="truncate">
{assignment.customerName ?? "—"}
</Text>
</Group>
{origin || destination ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" c="dimmed" fw={600}>
{origin ?? "Origin"}
</Text>
<ArrowRight size={13} className="shrink-0 text-edr-muted" />
<Text size="sm" c="dimmed" fw={600}>
{destination ?? "Destination"}
</Text>
</Group>
) : null}
</Group>
</div>
</Group>
</Group>
</Stack>
{!assignedBookingsUnlocked ? (
<Alert color="yellow" variant="light" radius="md">
Your transit agent registration is still under review. You can see
this booking, but reviewing its documents, uploading, and assigning
a Djibouti transit agent unlock once it is approved.
</Alert>
) : null}
{/* ── KPI strip ───────────────────────────────────────────── */}
{assignedBookingsUnlocked ? (
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
{kpis.map((k) => (
<Card key={k.label} withBorder radius="lg" p="md" shadow="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={k.color} radius="md" size={36}>
<k.icon size={18} />
</ThemeIcon>
<Box>
<Text fz={11} fw={600} tt="uppercase" c="edr-muted" style={{ letterSpacing: "0.06em" }}>
{k.label}
</Text>
<Text fz={22} fw={800} lh={1.1} c="edr-text">
{k.value}
</Text>
</Box>
</Group>
</Card>
))}
</SimpleGrid>
) : null}
<Tabs defaultValue="clearance" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="clearance" leftSection={<ClipboardList size={14} />}>
Clearance
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="clearance">
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="md">
<div className="flex size-9 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
<FileText size={18} />
</div>
<Box>
<Title order={4}>Document review</Title>
<Text c="edr-muted" size="sm">
The customer's paperwork, reviewed by you as the
clearing agent.
</Text>
</Box>
</Group>
{!assignedBookingsUnlocked ? (
<Alert color="yellow" variant="light" radius="md">
The document list opens once your role is approved.
</Alert>
) : bookingQuery.isPending ? (
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="edr-muted" size="sm">
Loading the booking…
</Text>
</Group>
) : bookingQuery.isError || !booking ? (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
The booking could not be loaded.
</Alert>
) : (
<ForwarderDocumentReview booking={booking} onChanged={refresh} />
)}
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{assignedBookingsUnlocked && bookingId ? (
<AdditionalDocsRequestCard
bookingId={bookingId}
requests={clearance?.docRequests ?? []}
canRequest={clearance?.documentsOpen !== false}
onSent={refresh}
/>
) : null}
<DjiboutiAgentCard assignment={assignment} />
<BookingFactsCard assignment={assignment} booking={booking ?? null} />
{assignedBookingsUnlocked && clearance ? (
<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}>
<PackageCheck size={16} />
</ThemeIcon>
<Title order={5}>Review progress</Title>
</Group>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
</Stack>
</Card>
) : null}
</Stack>
</Grid.Col>
</Grid>
</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>
)}
</Tabs.Panel>
</Tabs>
</Stack>
</Box>
);
@@ -227,3 +485,361 @@ function Fact({
</Box>
);
}
/** Who the booking belongs to and where the assignment stands — the side card. */
function BookingFactsCard({
assignment,
booking,
}: {
assignment: TransitAssignment;
booking: Freight.IBooking | null;
}) {
const contractRef = (booking as { contract?: { reference?: string | null } } | null)
?.contract?.reference;
return (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="sm">
<ThemeIcon variant="light" color="gray" radius="md" size={32}>
<Building2 size={16} />
</ThemeIcon>
<Title order={5}>Customer & booking</Title>
</Group>
<Stack gap="sm">
<Fact
icon={<Building2 size={14} />}
label="Customer"
value={assignment.customerName ?? "—"}
/>
{contractRef ? (
<Fact icon={<FileText size={14} />} label="Contract" value={contractRef} />
) : null}
<Group gap="xl" wrap="wrap">
<Fact icon={<Clock3 size={14} />} label="Assigned" value={formatDate(assignment.assignedAt)} />
<Fact icon={<Clock3 size={14} />} label="Started" value={formatDate(assignment.startedAt)} />
<Fact icon={<Clock3 size={14} />} label="Finished" value={formatDate(assignment.finishedAt)} />
</Group>
{assignment.note ? (
<Text fz={13} c="edr-text" style={{ whiteSpace: "pre-wrap" }}>
{assignment.note}
</Text>
) : null}
</Stack>
</Card>
);
}
/**
* Ask the customer for additional document(s) in plain words — the same card
* the GL desk has. The note, its author and its time show on the customer's
* booking page beside the upload box.
*/
function AdditionalDocsRequestCard({
bookingId,
requests,
canRequest,
onSent,
}: {
bookingId: string;
requests: Freight.ClearanceDocRequest[];
canRequest: boolean;
onSent?: () => void;
}) {
const [note, setNote] = useState("");
const send = useMutation({
mutationFn: () =>
transitAssignmentsService.requestAdditionalDocuments(bookingId, note.trim()),
onSuccess: () => {
toast.success("Request sent to the customer");
setNote("");
onSent?.();
},
onError: (e: Error) => toast.error(apiMessage(e, "Could not send the request")),
});
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}>
<FilePlus2 size={16} />
</ThemeIcon>
<Box>
<Title order={5}>Request more documents</Title>
<Text c="edr-muted" size="xs">
The customer sees your note next to their upload box.
</Text>
</Box>
</Group>
{canRequest ? (
<Stack gap="xs">
<Textarea
autosize
minRows={2}
radius="md"
placeholder="e.g. Please upload the signed packing list and the insurance certificate."
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button
size="compact-sm"
color="red"
radius="md"
leftSection={<MessageSquare size={14} />}
disabled={!note.trim()}
loading={send.isPending}
onClick={() => send.mutate()}
>
Send request
</Button>
</Group>
</Stack>
) : (
<Text fz={13} c="dimmed">
Documents are closed for this booking.
</Text>
)}
{requests.length > 0 ? (
<Stack gap={6} mt="sm">
{requests.slice(0, 3).map((r) => (
<Text key={r.id} fz={12} c="dimmed">
<Text span c="edr-text">
{r.note}
</Text>{" "}
· {r.byName ?? "—"} · {formatDateTime(r.at)}
</Text>
))}
</Stack>
) : null}
</Card>
);
}
const ACTOR_BADGE: Record<
Freight.ClearanceHistoryEvent["actorType"],
{ label: string; color: string }
> = {
STAFF: { label: "Staff", color: "blue" },
CUSTOMER: { label: "Customer", color: "grape" },
SYSTEM: { label: "System", color: "gray" },
};
function eventMeta(action: string): { icon: typeof CircleDot; color: string } {
if (action.includes("APPROVED") || action.includes("FINALIZ")) {
return { icon: CheckCircle2, color: "edr-green" };
}
if (action.includes("QUERIED") || action.includes("REQUEST")) {
return { icon: MessageSquare, color: "red" };
}
if (action.includes("ASSIGN")) return { icon: UserCheck, color: "blue" };
if (action.includes("DOC") || action.includes("UPLOAD")) {
return { icon: FileText, color: "gray" };
}
return { icon: CircleDot, color: "gray" };
}
/** The clearance trail — reviews, requests, assignments — newest first. */
function HistoryPanel({ bookingId }: { bookingId: string }) {
const historyQuery = useQuery({
queryKey: ["forwarder-clearance-history", bookingId],
queryFn: () => transitAssignmentsService.clearanceHistory(bookingId),
});
const events = historyQuery.data ?? [];
if (historyQuery.isPending) {
return (
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading history</Text>
</Group>
);
}
if (historyQuery.isError) {
return (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
History is not available for this shipment.
</Alert>
);
}
if (events.length === 0) {
return (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed">
No clearance actions recorded yet. Approvals, queries, document
requests and assignments appear here automatically.
</Text>
</Paper>
);
}
return (
<Paper withBorder radius="md" p="lg" maw={760}>
<Timeline bulletSize={22} lineWidth={2} active={events.length - 1} color="gray">
{events.map((ev) => {
const meta = eventMeta(ev.action);
const Icon = meta.icon;
const actor = ACTOR_BADGE[ev.actorType];
const note = typeof ev.metadata?.note === "string" ? ev.metadata.note : null;
return (
<Timeline.Item
key={ev.id}
color={meta.color}
bullet={<Icon size={12} />}
title={
<Group gap={8} wrap="wrap">
<Text fz="13px" fw={600} c="edr-text" lh={1.35}>
{ev.label}
</Text>
<Badge size="xs" variant="light" color={actor.color} radius="sm">
{actor.label}
</Badge>
</Group>
}
>
<Text fz="11.5px" c="dimmed">
{ev.actorName ? `${ev.actorName} · ` : ""}
{formatDateTime(ev.at)}
</Text>
{note ? (
<Text fz="12px" c="red.8" mt={2}>
{note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
</Paper>
);
}
/**
* The Djibouti transit officer on this booking, named by the forwarder.
*
* The forwarder clears customs on the Ethiopian side; the transit leg through
* Djibouti is a separate officer from the Djibouti roster, and on a
* without-customs booking nobody at GL Djibouti is in the loop to name one —
* so the forwarder does it here. Picking again reassigns; the officer is
* told either way. Locked until the roster role is approved, like uploads.
*/
function DjiboutiAgentCard({ assignment }: { assignment: TransitAssignment }) {
const { assignedBookingsUnlocked } = useAuth();
const queryClient = useQueryClient();
const [picked, setPicked] = useState<string | null>(null);
const bookingQuery = useQuery({
...api.bookings.get.queryOptions({ input: { id: assignment.bookingId } }),
enabled: assignedBookingsUnlocked,
});
// The API spreads the booking entity into its response, so the officer's
// name is there even though the shared type does not declare it.
const currentName =
(bookingQuery.data as { transitAssigneeName?: string | null } | undefined)
?.transitAssigneeName ?? null;
const rosterQuery = useQuery({
queryKey: ["transit-agents", "djibouti-options"],
queryFn: transitAssignmentsService.djiboutiAgents,
enabled: assignedBookingsUnlocked,
staleTime: 5 * 60 * 1000,
});
const options = (rosterQuery.data ?? []).map((a) => ({
value: a.id,
label: a.name,
}));
const mutation = useMutation({
mutationFn: (transitAgentId: string) =>
transitAssignmentsService.assignDjiboutiAgent(
assignment.bookingId,
transitAgentId,
),
onSuccess: () => {
toast.success("Djibouti transit agent assigned and notified.");
setPicked(null);
void queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: assignment.bookingId }),
});
},
onError: (e: Error) => {
const data = (
e as { response?: { data?: { message?: string | string[] } } }
).response?.data;
const message = Array.isArray(data?.message)
? data.message.join(", ")
: data?.message;
toast.error(message || e.message || "Could not assign the transit agent");
},
});
return (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="xs">
<div className="flex size-9 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
<ShipWheel size={18} />
</div>
<Box>
<Title order={4}>Djibouti transit agent</Title>
<Text c="edr-muted" size="sm">
Hand the Djibouti transit leg of this shipment to an officer from
the Djibouti roster. They are notified when you assign them.
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap" mb="md">
<UserCheck size={14} className="shrink-0 text-edr-muted" />
<Text fz={13} c="edr-text">
{currentName ? (
<>
Currently assigned: <Text span fw={600}>{currentName}</Text>
</>
) : (
"No Djibouti transit agent assigned yet."
)}
</Text>
</Group>
{!assignedBookingsUnlocked ? (
<Alert color="yellow" variant="light" radius="md">
Your transit agent registration is still under review. Assigning a
Djibouti transit agent unlocks once it is approved.
</Alert>
) : (
<Group align="flex-end" gap="sm" wrap="wrap">
<Select
label={currentName ? "Change to" : "Transit agent"}
placeholder={
rosterQuery.isLoading
? "Loading the Djibouti roster…"
: options.length === 0
? "No Djibouti transit agents are listed yet"
: "Type to search by name"
}
data={options}
value={picked}
onChange={setPicked}
disabled={rosterQuery.isLoading || options.length === 0}
searchable
clearable
nothingFoundMessage="No transit agent matches that name"
radius="md"
w={{ base: "100%", sm: 320 }}
comboboxProps={{ withinPortal: true }}
/>
<Button
color="edr-green"
radius="md"
leftSection={<UserCheck size={16} />}
loading={mutation.isPending}
disabled={!picked}
onClick={() => picked && mutation.mutate(picked)}
>
{currentName ? "Reassign" : "Assign"}
</Button>
</Group>
)}
{rosterQuery.isError ? (
<Alert color="red" radius="md" mt="sm" icon={<AlertCircle size={16} />}>
The Djibouti roster could not be loaded.
</Alert>
) : null}
</Card>
);
}

View File

@@ -0,0 +1,460 @@
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
Eye,
FileText,
MessageSquare,
ShieldCheck,
Upload,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { ClearanceAdHocUploadSection } from "@/components/contracts/ClearanceAdHocUploadSection";
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
import { api } from "@/services/api";
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 BORDER = "#E6ECF2";
function apiMessage(e: Error, fallback: string): string {
const data = (e as { response?: { data?: { message?: string | string[] } } })
.response?.data;
const message = Array.isArray(data?.message)
? data.message.join(", ")
: data?.message;
return message || e.message || fallback;
}
/**
* The clearing agent's review of a booking's import/export documents — the
* same job the GL Ethiopia desk does on a customs booking, on the portal.
*
* Each customer document shows its file, review state and any query note.
* The forwarder can approve it, query it with a note (the customer is told
* and re-uploads), upload a missing or queried document on the customer's
* behalf, and — once every required document is approved — finalize, which
* moves the booking to CLEARANCE_READY so the customer can complete it.
*
* Uploads ride the customer's own flow controller so the staged files, the
* ad-hoc rows and the submit rules are exactly what the API accepts.
*/
export function ForwarderDocumentReview({
booking,
onChanged,
}: {
booking: Freight.IBooking;
onChanged?: () => void;
}) {
const queryClient = useQueryClient();
const flow = useClearanceFlow(booking);
const { view, viewer } = useFileViewer();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const refresh = () => {
void queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
void queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
void queryClient.invalidateQueries({
queryKey: ["forwarder-clearance-history", booking.id],
});
onChanged?.();
};
const review = useMutation({
mutationFn: (input: {
fileKey: string;
status: "APPROVED" | "QUERIED";
note?: string;
}) => transitAssignmentsService.reviewDocument(booking.id, input),
onSuccess: (_b, input) => {
toast.success(
input.status === "APPROVED"
? "Document approved"
: "Query sent to the customer",
);
setOpenQuery((o) => ({ ...o, [input.fileKey]: false }));
setQueryNotes((n) => ({ ...n, [input.fileKey]: "" }));
refresh();
},
onError: (e: Error) =>
toast.error(apiMessage(e, "Could not record the review")),
});
const finalize = useMutation({
mutationFn: () => transitAssignmentsService.finalizeClearance(booking.id),
onSuccess: () => {
toast.success(
"Clearance finalized — the customer can now complete the booking.",
);
refresh();
},
onError: (e: Error) =>
toast.error(apiMessage(e, "Could not finalize clearance")),
});
if (flow.isLoading || !flow.clearance) {
return (
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="edr-muted" size="sm">
Loading the document list
</Text>
</Group>
);
}
const { clearance, customerDocs, canUpload, pending, adHoc, status } = flow;
const docNoun = bookingDocNoun(booking);
const reviewOpen = clearance.documentsOpen ?? canUpload;
const canFinalize =
status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
const finalized = [
"CLEARANCE_READY",
"OPERATION_REQUEST_PENDING",
"OPERATION_REQUESTED",
"OPERATION_CHANGES_REQUESTED",
].includes(status);
return (
<Stack gap="md">
{finalized ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
Clearance is finalized. The customer completes the booking from
here; documents stay open for additions until the shipment is paid.
</Alert>
) : status === "AWAITING_DOCUMENTS" ? (
<Alert color="yellow" radius="md" icon={<Upload size={18} />}>
Waiting for the {docNoun}. Upload them on the customer's behalf
below, or wait for the customer to upload; review starts once the
required set is in.
</Alert>
) : (
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
Review each document: approve it, or query it with a note the
customer sees. Finalize once every required document is approved.
</Alert>
)}
<Box>
<Text fz={13} fw={700} c="#10202F">
The customer's {docNoun}
</Text>
<Text fz={12} c="dimmed" mt={4}>
Items marked * are required before clearance can be finalized.
</Text>
</Box>
<Stack gap="sm">
{customerDocs.map((doc) => {
const hasFile = Boolean(doc.file);
const reviewable =
reviewOpen && hasFile && doc.reviewStatus !== "APPROVED";
const queryOpen = openQuery[doc.fileKey] ?? false;
return (
<Box key={doc.fileKey}>
<ClearanceDocumentUploadCard
label={doc.label}
required={doc.required}
reviewStatus={doc.reviewStatus}
note={doc.note}
uploadedFile={doc.file}
stagedFile={pending[doc.fileKey] ?? null}
canUpload={canUpload}
onStageFile={
canUpload && doc.reviewStatus !== "APPROVED"
? (file) => flow.stagePending(doc.fileKey, file)
: undefined
}
onPreview={view}
/>
{reviewable ? (
<Box
mt={-6}
px="md"
py={10}
style={{
border: `1px solid ${BORDER}`,
borderTop: 0,
borderRadius: "0 0 12px 12px",
background: "#FAFBFC",
}}
>
<Group gap="sm" wrap="wrap" align="center">
<Text fz={12} c="dimmed">
{doc.reviewStatus === "QUERIED"
? "Queried — awaiting the customer's re-upload."
: "Your decision:"}
</Text>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
loading={
review.isPending &&
review.variables?.fileKey === doc.fileKey &&
review.variables?.status === "APPROVED"
}
onClick={() =>
review.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
}
>
Approve
</Button>
{doc.reviewStatus !== "QUERIED" ? (
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquare size={14} />}
onClick={() =>
setOpenQuery((o) => ({
...o,
[doc.fileKey]: !queryOpen,
}))
}
>
Query
</Button>
) : null}
</Group>
{queryOpen ? (
<Stack gap={6} mt="sm">
<Textarea
autosize
minRows={2}
radius="md"
placeholder="What is wrong with this document, and what should the customer send instead?"
value={queryNotes[doc.fileKey] ?? ""}
onChange={(e) =>
setQueryNotes((n) => ({
...n,
[doc.fileKey]: e.currentTarget.value,
}))
}
/>
<Group justify="flex-end" gap="xs">
<Button
size="compact-sm"
variant="default"
radius="md"
onClick={() =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: false }))
}
>
Cancel
</Button>
<Button
size="compact-sm"
color="red"
radius="md"
disabled={!(queryNotes[doc.fileKey] ?? "").trim()}
loading={
review.isPending &&
review.variables?.fileKey === doc.fileKey &&
review.variables?.status === "QUERIED"
}
onClick={() =>
review.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: (queryNotes[doc.fileKey] ?? "").trim(),
})
}
>
Send query
</Button>
</Group>
</Stack>
) : null}
</Box>
) : null}
</Box>
);
})}
</Stack>
{flow.workflowFiles.length > 0 ? (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="sm">
Clearance documents from Global Logistics
</Text>
<Stack gap={8}>
{flow.workflowFiles.map((doc) => (
<Group
key={doc.code}
justify="space-between"
wrap="nowrap"
style={{
border: `1px solid ${BORDER}`,
borderRadius: 12,
padding: 10,
}}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
{isViewable({ name: doc.file.name, url: "" }) ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={() =>
void fetchViewableFile(doc.file.id, doc.file.name).then(
view,
)
}
>
<Eye size={15} />
</Button>
) : null}
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={() =>
void downloadStoredFile(doc.file.id, doc.file.name)
}
>
<Download size={15} />
</Button>
</Group>
</Group>
))}
</Stack>
</>
) : null}
{(clearance.docRequests?.length ?? 0) > 0 ? (
<Box>
<Text fz="12.5px" fw={700} c="#C0392B" mb={8}>
Documents requested from the customer
</Text>
<Stack gap={8}>
{clearance.docRequests!.map((r) => (
<Alert
key={r.id}
color="red"
variant="light"
radius="md"
icon={<MessageSquare size={16} />}
p="xs"
>
<Text fz="12.5px" c="#10202F">
{r.note}
</Text>
<Text fz="11px" c="dimmed" mt={4}>
{r.byName ?? "Clearing agent"} ·{" "}
{new Date(r.at).toLocaleString()}
</Text>
</Alert>
))}
</Stack>
</Box>
) : null}
{canUpload ? (
<ClearanceAdHocUploadSection
rows={adHoc}
onAdd={flow.addAdHocRow}
onRemove={flow.removeAdHocRow}
onNameChange={flow.setAdHocName}
onFileChange={flow.setAdHocFile}
onPreview={view}
/>
) : null}
{flow.uploadMutation.isError ? (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{flow.uploadMutation.error instanceof Error
? flow.uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
) : null}
<Group justify="space-between" gap="sm" wrap="wrap" mt="sm">
<Group gap={6}>
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={12} />}
>
All required documents approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={12} />}
>
Review in progress
</Badge>
)}
</Group>
<Group gap="sm">
{canUpload ? (
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => flow.submitDocuments()}
loading={flow.uploadMutation.isPending}
disabled={!flow.canSubmit}
>
Submit documents
</Button>
) : null}
{!finalized ? (
<Button
color="edr-green"
radius="md"
leftSection={<ShieldCheck size={16} />}
onClick={() => finalize.mutate()}
loading={finalize.isPending}
disabled={!canFinalize}
>
Finalize clearance
</Button>
) : null}
</Group>
</Group>
{viewer}
</Stack>
);
}

View File

@@ -44,6 +44,21 @@ function isMilestoneDone(
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
const formatStamp = (value: string): string => new Date(value).toLocaleString();
/**
* The rail leg's own stamps, so a train that has left reads as in transit
* rather than untouched — the step only completes on arrival.
*/
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";
}
/**
* The clearance stepper a transit agent sees on a shipment assigned to them,
* laid out as the backoffice's clearance action panel is, plus the RO
@@ -203,7 +218,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Train to Djibouti",
description: "Departure and arrival",
description: trainLegDescription(clearance?.train),
done: Boolean(clearance?.train?.arrivedAt),
},
{

View File

@@ -1,5 +1,6 @@
import type { Freight } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "@/utils/api";
const BASE = "/api/transit-assignments/my";
@@ -365,6 +366,65 @@ export const transitAssignmentsService = {
);
},
// ── Djibouti officer, named by the assigned clearing agent ───────────────
// A without-customs booking has no GL Djibouti desk in the loop, so the
// forwarder the customer assigned hands the transit leg to a Djibouti
// officer itself. The API accepts this only from the agent assigned to the
// booking, and only for an active Djiboutian roster entry.
/** The Djibouti roster, id + name only. */
djiboutiAgents: async (): Promise<{ id: string; name: string }[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRANSIT_AGENTS_API.DJIBOUTI_OPTIONS,
);
return data.data ?? data;
},
/** Name (or change) the Djibouti transit officer on an assigned booking. */
assignDjiboutiAgent: async (
bookingId: string,
transitAgentId: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${bookingId}/clearance/transit-assignee/assign`,
{ transitAgentId },
);
return data.data ?? data;
},
// ── Clearing-agent review, on a without-customs booking ──────────────────
// The forwarder the customer assigned clears customs in GL's place, so it
// works the same review the GL desk does: approve or query each document,
// ask the customer for more, and finalize once everything is approved. The
// API accepts these only from the agent assigned to the booking.
reviewDocument: async (
bookingId: string,
input: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${bookingId}/clearance/review`,
input,
);
return data.data ?? data;
},
requestAdditionalDocuments: async (
bookingId: string,
note: string,
): Promise<void> => {
await client.post(`/api/bookings/${bookingId}/clearance/doc-requests`, {
note,
});
},
finalizeClearance: async (bookingId: string): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${bookingId}/clearance/finalize`,
);
return data.data ?? data;
},
/** T1 transit documents (import); locked once GL Ethiopia closes the T1. */
uploadT1Documents: async (
bookingId: string,