mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
feat: implement document clearance workflow for bookings
- Added ClearanceCard component to display and manage clearance documents in ReadonlyBookingView. - Introduced new API endpoints for clearance operations: getClearance, submitClearanceDocuments, and proceedToOperation. - Created BookingDocumentReview entity and migration for document review status tracking. - Developed GlClearancePage for Global Logistics to review and manage document submissions. - Implemented utility functions for determining clearance setting codes based on trade direction and freight type. - Added tests for booking transition clearance logic and clearance utility functions.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
Paperclip,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
@@ -27,6 +28,7 @@ import LoginPage from "./pages/auth/LoginPage";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import GlClearancePage from "./pages/bookings/GlClearancePage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
@@ -109,6 +111,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
title: "Operations",
|
||||
items: [
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
@@ -376,6 +384,14 @@ const App = () => {
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route
|
||||
path="clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
|
||||
<GlClearancePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
|
||||
@@ -15,6 +15,9 @@ export const FREIGHT_PERMS = {
|
||||
signStaff: "edr_freight_app:bookings:sign_staff",
|
||||
operations: "edr_freight_app:bookings:operations",
|
||||
cancel: "edr_freight_app:bookings:cancel",
|
||||
reviewDocuments: "edr_freight_app:bookings:review_documents",
|
||||
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
|
||||
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||
|
||||
export default function GlClearancePage() {
|
||||
const qc = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// Bookings currently awaiting GL document review.
|
||||
const { data: list, isLoading } = useQuery({
|
||||
queryKey: ["gl-clearance", "list"],
|
||||
queryFn: () => bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
|
||||
});
|
||||
|
||||
const bookings = list?.items ?? [];
|
||||
const activeId = selectedId ?? bookings[0]?.id ?? null;
|
||||
|
||||
return (
|
||||
<Box p="lg">
|
||||
<Group gap={10} mb="lg">
|
||||
<ShieldCheck size={22} color="#0A6F4D" />
|
||||
<Text fw={800} fz="22px" c="#10202F">
|
||||
Document Clearance
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<Card withBorder radius="md" p="sm" style={{ width: 300, flexShrink: 0 }}>
|
||||
<Text fz="13px" fw={700} c="#10202F" mb="xs">
|
||||
Awaiting review ({bookings.length})
|
||||
</Text>
|
||||
{isLoading && (
|
||||
<Text fz="13px" c="dimmed">
|
||||
Loading…
|
||||
</Text>
|
||||
)}
|
||||
{!isLoading && bookings.length === 0 && (
|
||||
<Text fz="13px" c="dimmed">
|
||||
No bookings awaiting document review.
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap={6}>
|
||||
{bookings.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(b.id)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
border: `1px solid ${b.id === activeId ? "#0A6F4D" : "#E6ECF2"}`,
|
||||
background: b.id === activeId ? "#F4FBF7" : "#fff",
|
||||
borderRadius: 10,
|
||||
padding: "8px 10px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Text fz="13px" fw={600} c="#10202F">
|
||||
{b.reference}
|
||||
</Text>
|
||||
<Text fz="11.5px" c="dimmed">
|
||||
{b.tradeDirection} · {b.freightType}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
{activeId ? (
|
||||
<ClearanceReviewPanel
|
||||
bookingId={activeId}
|
||||
onChanged={() =>
|
||||
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text c="dimmed">Select a booking to review its documents.</Text>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceReviewPanel({
|
||||
bookingId,
|
||||
onChanged,
|
||||
}: {
|
||||
bookingId: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||
|
||||
const { data: clearance, isLoading } = useQuery({
|
||||
queryKey: ["gl-clearance", bookingId],
|
||||
queryFn: () => bookingsService.getClearance(bookingId),
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] });
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: (p: {
|
||||
fileKey: string;
|
||||
status: "APPROVED" | "QUERIED";
|
||||
note?: string;
|
||||
}) => bookingsService.reviewClearanceDocument(bookingId, p),
|
||||
onSuccess: () => {
|
||||
toast.success("Document updated");
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Could not update document"),
|
||||
});
|
||||
|
||||
const outputMutation = useMutation({
|
||||
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
|
||||
onSuccess: () => {
|
||||
toast.success("Output documents uploaded");
|
||||
setOutputFiles({});
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Upload failed"),
|
||||
});
|
||||
|
||||
const finalizeMutation = useMutation({
|
||||
mutationFn: () => bookingsService.finalizeClearance(bookingId),
|
||||
onSuccess: () => {
|
||||
toast.success("Clearance finalized");
|
||||
refresh();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : "Could not finalize clearance"),
|
||||
});
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} c="#10202F">
|
||||
Customer documents
|
||||
</Text>
|
||||
{clearance.allApproved ? (
|
||||
<Group gap={6} c="#0A6F4D">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text fz="12.5px" fw={600} c="#0A6F4D">
|
||||
All approved
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={6} c="#2E5B96">
|
||||
<Clock size={16} />
|
||||
<Text fz="12.5px" fw={600} c="#2E5B96">
|
||||
Review pending
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Stack gap={12}>
|
||||
{customerDocs.map((doc) => (
|
||||
<DocReviewRow
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
onNote={(v) =>
|
||||
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
|
||||
}
|
||||
onApprove={() =>
|
||||
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
|
||||
}
|
||||
onQuery={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "QUERIED",
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{clearance.outputCode && (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text fw={700} c="#10202F" mb="md">
|
||||
Customs output documents
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{glDocs.map((doc) => (
|
||||
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={16} color="#2E5B96" />
|
||||
<Text fz="13px" c="#10202F" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.file ? (
|
||||
<a href={doc.file.url} target="_blank" rel="noreferrer">
|
||||
<Download size={15} />
|
||||
</a>
|
||||
) : (
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
Not uploaded
|
||||
</Text>
|
||||
)}
|
||||
<FileButton
|
||||
onChange={(f) =>
|
||||
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
|
||||
}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={13} />}
|
||||
>
|
||||
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={15} />}
|
||||
disabled={Object.keys(outputFiles).length === 0}
|
||||
loading={outputMutation.isPending}
|
||||
onClick={() => outputMutation.mutate()}
|
||||
>
|
||||
Upload output documents
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{finalizeMutation.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
{finalizeMutation.error instanceof Error
|
||||
? finalizeMutation.error.message
|
||||
: "Could not finalize clearance."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled={!clearance.allApproved}
|
||||
loading={finalizeMutation.isPending}
|
||||
onClick={() => finalizeMutation.mutate()}
|
||||
>
|
||||
Finalize clearance
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DocReviewRow({
|
||||
doc,
|
||||
note,
|
||||
onNote,
|
||||
onApprove,
|
||||
onQuery,
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ClearanceDocument;
|
||||
note: string;
|
||||
onNote: (v: string) => void;
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box className="rounded-xl" style={{ border: "1px solid #E6ECF2", padding: 12 }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={18} color="#2E5B96" />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
<Text fz="12px" c="dimmed" truncate>
|
||||
{doc.file ? doc.file.name : "Not uploaded"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.reviewStatus === "APPROVED" && (
|
||||
<Text fz="12px" fw={600} c="#0A6F4D">
|
||||
Approved
|
||||
</Text>
|
||||
)}
|
||||
{doc.reviewStatus === "QUERIED" && (
|
||||
<Text fz="12px" fw={600} c="#C0392B">
|
||||
Queried
|
||||
</Text>
|
||||
)}
|
||||
{doc.file && (
|
||||
<a href={doc.file.url} target="_blank" rel="noreferrer">
|
||||
<Download size={15} />
|
||||
</a>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{doc.file && (
|
||||
<Group gap={8} mt={10} align="flex-end" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Query note (required to query)"
|
||||
value={note}
|
||||
onChange={(e) => onNote(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
size="xs"
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={busy || !note.trim()}
|
||||
onClick={onQuery}
|
||||
>
|
||||
Query
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
const B = URL_CONSTANTS.BOOKINGS;
|
||||
|
||||
@@ -169,6 +170,36 @@ export const bookingsService = {
|
||||
await client.delete(B.BY_ID(id));
|
||||
},
|
||||
|
||||
// ── Document clearance (GL workflow) ──
|
||||
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||
const response = await client.get(`/bookings/${id}/clearance`);
|
||||
return unwrap(response.data) as Freight.ClearanceView;
|
||||
},
|
||||
|
||||
reviewClearanceDocument: (
|
||||
id: string,
|
||||
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
||||
) => postBooking<BookingDetail>(`/bookings/${id}/clearance/review`, payload),
|
||||
|
||||
uploadClearanceOutput: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(
|
||||
`/bookings/${id}/clearance/output-documents`,
|
||||
form,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
finalizeClearance: (id: string) =>
|
||||
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
|
||||
|
||||
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
|
||||
|
||||
requestChanges: (id: string, note: string) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { paymentsService, type PaymentMethod } from "@/services/payments.service
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { DocRow, IconSquare } from "./components/Documents";
|
||||
@@ -62,6 +63,12 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
const isClearance = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
"OPERATION_REQUESTED",
|
||||
].includes(status);
|
||||
// Paired: a consolidation partner was found and the booking resumed the normal
|
||||
// flow. Surface the "partner found" reassurance only in the early stages,
|
||||
// before approval, so it doesn't linger for the rest of the booking's life.
|
||||
@@ -126,6 +133,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
<ContractCard booking={booking} navigate={navigate} />
|
||||
|
||||
{isClearance && <ClearanceCard booking={booking} />}
|
||||
|
||||
<BodyGrid
|
||||
left={
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
FileText,
|
||||
Plus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
import { IconSquare } from "./Documents";
|
||||
|
||||
const GREEN = "#0A6F4D";
|
||||
|
||||
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
|
||||
if (doc.reviewStatus === "APPROVED") {
|
||||
return (
|
||||
<Group gap={6} c={GREEN}>
|
||||
<CheckCircle2 size={15} />
|
||||
<Text fz="12px" fw={600} c={GREEN}>
|
||||
Approved
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (doc.reviewStatus === "QUERIED") {
|
||||
return (
|
||||
<Group gap={6} c="#C0392B">
|
||||
<AlertCircle size={15} />
|
||||
<Text fz="12px" fw={600} c="#C0392B">
|
||||
Queried
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (doc.file) {
|
||||
return (
|
||||
<Group gap={6} c="#2E5B96">
|
||||
<Clock size={15} />
|
||||
<Text fz="12px" fw={600} c="#2E5B96">
|
||||
Pending review
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text fz="12px" fw={600} c="#9AA8B5">
|
||||
Not uploaded
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-facing clearance section: shows the resolved document grid, lets the
|
||||
* customer (re)upload pending/queried documents plus ad-hoc named documents, and
|
||||
* proceed to operation once Global Logistics marks the booking CLEARANCE_READY.
|
||||
*/
|
||||
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const status = booking.status as string;
|
||||
|
||||
const { data: clearance, isLoading } = useQuery(
|
||||
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
|
||||
);
|
||||
|
||||
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
const [adHoc, setAdHoc] = useState<Array<{ name: string; file: File | null }>>(
|
||||
[],
|
||||
);
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.get.queryKey({ id: booking.id }),
|
||||
});
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
...api.bookings.submitClearanceDocuments.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
setPending({});
|
||||
setAdHoc([]);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const proceedMutation = useMutation({
|
||||
...api.bookings.proceedToOperation.mutationOptions(),
|
||||
onSuccess: () => refresh(),
|
||||
});
|
||||
|
||||
// Only the customer-input documents are uploadable here; GL output docs are
|
||||
// shown read-only.
|
||||
const customerDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
if (status === "OPERATION_REQUESTED") {
|
||||
return (
|
||||
<SectionCard>
|
||||
<CardTitle>Operation</CardTitle>
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mt="sm">
|
||||
Operation requested. An operator will take your shipment forward.
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<SectionCard>
|
||||
<CardTitle>Clearance documents</CardTitle>
|
||||
<Text fz="13px" c="dimmed" mt="sm">
|
||||
Loading clearance…
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const isReady = status === "CLEARANCE_READY";
|
||||
const canUpload =
|
||||
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
||||
|
||||
function handleSubmit() {
|
||||
const files: Record<string, File | null> = { ...pending };
|
||||
adHoc.forEach((row, i) => {
|
||||
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
||||
});
|
||||
if (Object.keys(files).length === 0) return;
|
||||
uploadMutation.mutate({ id: booking.id, files });
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Clearance documents</CardTitle>
|
||||
{clearance.includesCustoms && (
|
||||
<Text fz="12px" fw={600} c="#9AA8B5">
|
||||
Customs clearance
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isReady ? (
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||
Clearance is ready. You can now proceed to operation.
|
||||
</Alert>
|
||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||
Global Logistics is reviewing your documents. Queried documents below
|
||||
need to be re-uploaded.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
||||
Upload the documents below to start the clearance review.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap={10}>
|
||||
{customerDocs.map((doc) => (
|
||||
<Box
|
||||
key={doc.fileKey}
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box c="#2E5B96">
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
{doc.file && (
|
||||
<Text fz="12px" c="dimmed" truncate>
|
||||
{doc.file.name}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<StatusPill doc={doc} />
|
||||
{doc.file && (
|
||||
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
|
||||
)}
|
||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
||||
<FileButton
|
||||
onChange={(f) =>
|
||||
f && setPending((p) => ({ ...p, [doc.fileKey]: f }))
|
||||
}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={13} />}
|
||||
>
|
||||
{pending[doc.fileKey] ? "Selected" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{doc.reviewStatus === "QUERIED" && doc.note && (
|
||||
<Text fz="12px" c="#C0392B" mt={6}>
|
||||
Query: {doc.note}
|
||||
</Text>
|
||||
)}
|
||||
{pending[doc.fileKey] && (
|
||||
<Text fz="12px" c={GREEN} mt={6}>
|
||||
Ready to upload: {pending[doc.fileKey].name}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* GL output documents (read-only to the customer). */}
|
||||
{glDocs.length > 0 && (
|
||||
<>
|
||||
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
|
||||
Customs output documents
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
{glDocs.map((doc) => (
|
||||
<Group
|
||||
key={doc.fileKey}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 10 }}
|
||||
>
|
||||
<Text fz="13px" c="#10202F" truncate>
|
||||
{doc.label}
|
||||
</Text>
|
||||
{doc.file ? (
|
||||
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
|
||||
) : (
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
Pending
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ad-hoc / additional documents. */}
|
||||
{canUpload && (
|
||||
<Box mt="lg">
|
||||
<Group justify="space-between" align="center" mb={8}>
|
||||
<Text fz="12.5px" fw={700} c="#10202F">
|
||||
Additional documents
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={13} />}
|
||||
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
|
||||
>
|
||||
Add document
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap={8}>
|
||||
{adHoc.map((row, i) => (
|
||||
<Group key={i} gap={8} wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Document name"
|
||||
value={row.name}
|
||||
onChange={(e) =>
|
||||
setAdHoc((rows) =>
|
||||
rows.map((r, j) =>
|
||||
j === i ? { ...r, name: e.currentTarget.value } : r,
|
||||
),
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
/>
|
||||
<FileButton
|
||||
onChange={(f) =>
|
||||
setAdHoc((rows) =>
|
||||
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
||||
)
|
||||
}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button {...props} variant="default" radius="md">
|
||||
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
||||
{uploadMutation.error instanceof Error
|
||||
? uploadMutation.error.message
|
||||
: "Upload failed. Please try again."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="lg" gap="sm">
|
||||
{canUpload && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={handleSubmit}
|
||||
loading={uploadMutation.isPending}
|
||||
disabled={
|
||||
Object.keys(pending).length === 0 &&
|
||||
!adHoc.some((r) => r.file)
|
||||
}
|
||||
>
|
||||
Submit documents
|
||||
</Button>
|
||||
)}
|
||||
{isReady && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={() =>
|
||||
proceedMutation.mutate(
|
||||
{ id: booking.id },
|
||||
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
|
||||
)
|
||||
}
|
||||
loading={proceedMutation.isPending}
|
||||
>
|
||||
Proceed to operation
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -256,6 +256,25 @@ export const api = {
|
||||
bookingsService.uploadDocuments(id, files),
|
||||
),
|
||||
|
||||
getClearance: endpoint<{ id: string }, Freight.ClearanceView>(
|
||||
"bookings",
|
||||
"getClearance",
|
||||
({ id }) => bookingsService.getClearance(id),
|
||||
),
|
||||
|
||||
submitClearanceDocuments: endpoint<
|
||||
{ id: string; files: Record<string, File | null> },
|
||||
Freight.IBooking
|
||||
>("bookings", "submitClearanceDocuments", ({ id, files }) =>
|
||||
bookingsService.submitClearanceDocuments(id, files),
|
||||
),
|
||||
|
||||
proceedToOperation: endpoint<{ id: string }, Freight.IBooking>(
|
||||
"bookings",
|
||||
"proceedToOperation",
|
||||
({ id }) => bookingsService.proceedToOperation(id),
|
||||
),
|
||||
|
||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||
"bookings",
|
||||
"checkPayment",
|
||||
|
||||
@@ -184,6 +184,33 @@ export const bookingsService = {
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// ── Document clearance ──
|
||||
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/clearance`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
submitClearanceDocuments: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const formData = new FormData();
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) formData.append(key, file);
|
||||
}
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/documents`,
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
proceedToOperation: async (id: string): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getContractView: async (id: string): Promise<ContractView> => {
|
||||
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
||||
return data.data ?? data;
|
||||
|
||||
Reference in New Issue
Block a user