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:
Marshal
2026-06-23 21:33:34 +00:00
parent 29f6928059
commit 6485cbdd71
24 changed files with 1924 additions and 6 deletions

View File

@@ -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 />} />

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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) =>