mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
changes on transit agent
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user