mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: enhance contract detail page with document grouping and clearance modal
- Added functionality to group contract documents into categories: Contract, Profile, and Clearance. - Implemented a modal for uploading and managing clearance documents. - Created a new ActionNeededSection component to display pending customer actions. - Introduced deriveActionItems function to generate actionable items based on contract and booking statuses. - Developed ContractClearancePanel for handling clearance document uploads and reviews. - Added support for ad-hoc document uploads in the clearance panel.
This commit is contained in:
@@ -294,7 +294,15 @@ export class ContractClearanceService {
|
||||
note?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
|
||||
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
|
||||
// the contract to "awaiting" (the customer must re-upload), but the reviewer
|
||||
// may still be working through the rest of the batch. Restricting to
|
||||
// UNDER_REVIEW only would 409 every review after the first query.
|
||||
if (
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import {
|
||||
ActionNeededSection,
|
||||
FreightVolumeSection,
|
||||
HelloSection,
|
||||
InvoicesSection,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
ShipmentsSection,
|
||||
StatsSection,
|
||||
} from "./components";
|
||||
import { deriveActionItems } from "./actions";
|
||||
import { useMyPortalData } from "./hooks";
|
||||
|
||||
export default function MyPortalPage() {
|
||||
@@ -25,6 +27,7 @@ export default function MyPortalPage() {
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
contractsQuery,
|
||||
allContracts,
|
||||
recentContracts,
|
||||
activeContractsCount,
|
||||
allBookings,
|
||||
@@ -45,6 +48,8 @@ export default function MyPortalPage() {
|
||||
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
|
||||
}));
|
||||
|
||||
const actionItems = deriveActionItems(allContracts, allBookings);
|
||||
|
||||
const handleBookingClick = (id: string) => {
|
||||
navigate(`/bookings/${id}`);
|
||||
};
|
||||
@@ -53,6 +58,8 @@ export default function MyPortalPage() {
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<HelloSection greeting={greeting} companyName={companyName} />
|
||||
|
||||
<ActionNeededSection items={actionItems} />
|
||||
|
||||
{serviceOptions.length > 1 && (
|
||||
<Group justify="flex-end">
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/** A pending customer action surfaced on the home "needs attention" card. */
|
||||
export interface ActionItem {
|
||||
id: string;
|
||||
/** What the customer must do — drives the icon, label and modal. */
|
||||
kind: "clearance" | "sign" | "book" | "pay";
|
||||
/** The contract/booking reference for display. */
|
||||
reference: string;
|
||||
/** Short human description of the action. */
|
||||
description: string;
|
||||
/** Contract id (clearance/sign/book) or booking id (pay). */
|
||||
targetId: string;
|
||||
/** True for queried clearance (a document was sent back for correction). */
|
||||
urgent?: boolean;
|
||||
}
|
||||
|
||||
const CLEARANCE_UPLOAD_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
];
|
||||
|
||||
/**
|
||||
* Derive the list of pending customer actions from the customer's contracts and
|
||||
* bookings, using status alone (no extra per-record fetch). A contract back in
|
||||
* AWAITING_CLEARANCE_DOCUMENTS after review means a document was queried and
|
||||
* needs the customer's attention — flagged urgent.
|
||||
*/
|
||||
export function deriveActionItems(
|
||||
contracts: Freight.IContract[],
|
||||
bookings: Freight.IBooking[],
|
||||
): ActionItem[] {
|
||||
const items: ActionItem[] = [];
|
||||
|
||||
for (const c of contracts) {
|
||||
if (c.status === "CONTRACT_READY") {
|
||||
items.push({
|
||||
id: `sign-${c.id}`,
|
||||
kind: "sign",
|
||||
reference: c.reference,
|
||||
description: "Contract ready to sign",
|
||||
targetId: c.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (CLEARANCE_UPLOAD_STATUSES.includes(c.status)) {
|
||||
const queried = c.status === "AWAITING_CLEARANCE_DOCUMENTS";
|
||||
items.push({
|
||||
id: `clearance-${c.id}`,
|
||||
kind: "clearance",
|
||||
reference: c.reference,
|
||||
description: queried
|
||||
? "Clearance document needs correction"
|
||||
: "Upload clearance documents",
|
||||
targetId: c.id,
|
||||
urgent: queried,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Path A transport-only: customer may create the shipment booking.
|
||||
if (
|
||||
!c.customsClearingEnabled &&
|
||||
(c.status === "FULLY_EXECUTED" || c.status === "CONTRACT_ACTIVE")
|
||||
) {
|
||||
items.push({
|
||||
id: `book-${c.id}`,
|
||||
kind: "book",
|
||||
reference: c.reference,
|
||||
description: "Ready to book a shipment",
|
||||
targetId: c.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const b of bookings) {
|
||||
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
||||
const canPay =
|
||||
b.paymentStatus !== "PAID" &&
|
||||
(isGeneral
|
||||
? b.status === "FULLY_EXECUTED"
|
||||
: b.status === "SELECTED_FOR_BATCH");
|
||||
if (canPay) {
|
||||
items.push({
|
||||
id: `pay-${b.id}`,
|
||||
kind: "pay",
|
||||
reference: b.reference,
|
||||
description: "Payment due for this shipment",
|
||||
targetId: b.id,
|
||||
urgent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the most pressing (urgent) actions first.
|
||||
return items.sort((a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Badge, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
FilePlus2,
|
||||
FileSignature,
|
||||
PackagePlus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
import { Card } from "./Card";
|
||||
import type { ActionItem } from "../actions";
|
||||
|
||||
const KIND_META: Record<
|
||||
ActionItem["kind"],
|
||||
{ icon: typeof Upload; label: string; color: string }
|
||||
> = {
|
||||
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
|
||||
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
||||
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
||||
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
||||
};
|
||||
|
||||
export interface ActionNeededSectionProps {
|
||||
items: ActionItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Home "needs your attention" card. Lists pending customer actions across
|
||||
* contracts and bookings. Clearance and payment open in a modal right here;
|
||||
* sign and book navigate to the relevant page.
|
||||
*/
|
||||
export function ActionNeededSection({ items }: ActionNeededSectionProps) {
|
||||
const navigate = useNavigate();
|
||||
const [clearanceId, setClearanceId] = useState<string | null>(null);
|
||||
const [payItem, setPayItem] = useState<ActionItem | null>(null);
|
||||
|
||||
// Mirror ReadonlyBookingView: POST /payments/initiate returns the provider's
|
||||
// redirect (clientAction.url); fall back to the public checkout page.
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) =>
|
||||
api.payments.initiate.call({ bookingId: payItem!.targetId, method }),
|
||||
onSuccess: (data, method) => {
|
||||
const bookingId = payItem!.targetId;
|
||||
const url =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrl({ bookingId, method });
|
||||
window.location.href = url;
|
||||
},
|
||||
});
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const handleClick = (item: ActionItem) => {
|
||||
switch (item.kind) {
|
||||
case "clearance":
|
||||
setClearanceId(item.targetId);
|
||||
break;
|
||||
case "pay":
|
||||
setPayItem(item);
|
||||
break;
|
||||
case "sign":
|
||||
navigate(`/contracts/${item.targetId}/view`);
|
||||
break;
|
||||
case "book":
|
||||
navigate(`/contracts/${item.targetId}/bookings/new`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding={0}>
|
||||
<Group justify="space-between" align="center" px={24} pt={20} pb={12}>
|
||||
<Group gap={8}>
|
||||
<AlertTriangle size={18} className="text-amber-500" />
|
||||
<Text fw={700} fz={16} c="edr-text">
|
||||
Needs your attention
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color="orange" variant="light" radius="sm">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap={0}>
|
||||
{items.map((item, i) => {
|
||||
const meta = KIND_META[item.kind];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Group
|
||||
key={item.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={24}
|
||||
py={14}
|
||||
style={{
|
||||
borderTop:
|
||||
i === 0
|
||||
? "none"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleClick(item)}
|
||||
className="hover:bg-edr-soft"
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
flexShrink: 0,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: `var(--mantine-color-${meta.color}-light)`,
|
||||
color: `var(--mantine-color-${meta.color}-filled)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} fz={14} c="edr-text" truncate>
|
||||
{item.reference}
|
||||
</Text>
|
||||
{item.urgent && (
|
||||
<Badge size="xs" color="red" variant="light" radius="sm">
|
||||
Action required
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={12.5} c="dimmed" truncate>
|
||||
{item.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color={meta.color}
|
||||
radius="md"
|
||||
leftSection={<FilePlus2 size={14} />}
|
||||
>
|
||||
{item.kind === "pay"
|
||||
? "Pay"
|
||||
: item.kind === "sign"
|
||||
? "Sign"
|
||||
: item.kind === "book"
|
||||
? "Book"
|
||||
: "Resolve"}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={clearanceId !== null}
|
||||
onClose={() => setClearanceId(null)}
|
||||
title={
|
||||
<Text fw={700} fz={16}>
|
||||
Clearance documents
|
||||
</Text>
|
||||
}
|
||||
size="xl"
|
||||
radius="md"
|
||||
centered
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
{clearanceId && (
|
||||
<ContractClearancePanel contractId={clearanceId} bare />
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payItem !== null}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
setPayItem(null);
|
||||
payMutation.reset();
|
||||
}
|
||||
}}
|
||||
currency={undefined}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ActionNeededSection } from "./ActionNeededSection";
|
||||
export { ActivityRow } from "./ActivityRow";
|
||||
export { BookingRow } from "./BookingRow";
|
||||
export { Card } from "./Card";
|
||||
|
||||
@@ -1,186 +1,43 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Plus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeft, Upload } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
||||
import { BORDER, ContractStatusBadge, INK } from "./contract-ui";
|
||||
|
||||
const GREEN = "#0A6F4D";
|
||||
|
||||
type AdHocDoc = { name: string; file: File | null };
|
||||
|
||||
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { ContractStatusBadge, INK } from "./contract-ui";
|
||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||
|
||||
/**
|
||||
* Path B customer clearance upload on the CONTRACT (doc §8.3). Mirrors the
|
||||
* booking `ClearanceFlow` but targets the contract clearance endpoints — no
|
||||
* booking exists yet. The customer uploads required documents (and duty/tax
|
||||
* slips when advised), Global Logistics reviews them, and after approval GL
|
||||
* creates the booking on the customer's behalf.
|
||||
* Customer clearance upload page for a CONTRACT (doc §8.3). The document grid +
|
||||
* upload logic live in {@link ContractClearancePanel} so the same workspace can
|
||||
* render here (full page) or inside the contract action modal.
|
||||
*/
|
||||
export default function ContractClearanceFlow() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: contract } = useQuery(
|
||||
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
||||
);
|
||||
|
||||
const clearanceQuery = useQuery(
|
||||
const { data: clearance } = useQuery(
|
||||
api.contracts.getClearance.queryOptions({
|
||||
input: { id: id! },
|
||||
enabled: !!id,
|
||||
}),
|
||||
);
|
||||
const clearance = clearanceQuery.data;
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
...api.contracts.uploadClearanceDocuments.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
setPending({});
|
||||
setAdHoc([]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.getClearance.queryKey({ id: id! }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
|
||||
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
||||
const isReady =
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
status === "SELF_CLEARED" ||
|
||||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||
|
||||
// Path B (customs) is reviewed by Global Logistics and GL creates the booking;
|
||||
// Path A self-clearance is reviewed by the Operations team and the customer
|
||||
// creates the booking himself afterward.
|
||||
const customsPath = clearance?.includesCustoms ?? true;
|
||||
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
||||
[customerDocs, pending],
|
||||
);
|
||||
|
||||
const hasStagedFiles =
|
||||
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
|
||||
|
||||
const canSubmit = isInitialUpload
|
||||
? hasStagedFiles && missingRequired.length === 0
|
||||
: hasStagedFiles;
|
||||
|
||||
const stagePending = (fileKey: string, file: File) =>
|
||||
setPending((p) => ({ ...p, [fileKey]: file }));
|
||||
|
||||
const submitDocuments = () => {
|
||||
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: id!, files });
|
||||
};
|
||||
|
||||
if (clearanceQuery.isLoading) {
|
||||
return (
|
||||
<Center mih={400} p="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<Button
|
||||
@@ -212,277 +69,8 @@ export default function ContractClearanceFlow() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
|
||||
<Stack gap={0}>
|
||||
{isReady ? (
|
||||
<Alert
|
||||
color="teal"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
{customsPath
|
||||
? "Your clearance documents are approved. Global Logistics will create your booking — you will be notified when payment is due."
|
||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||
</Alert>
|
||||
) : isUnderReview ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
radius="md"
|
||||
icon={<Clock size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is
|
||||
reviewing your documents. Only re-upload the documents flagged
|
||||
with a query below — approved documents stay as they are.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
{customsPath
|
||||
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
|
||||
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isInitialUpload && missingRequired.length > 0 && (
|
||||
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
||||
<Text fz="12px" c="#9A5B00">
|
||||
Still required:{" "}
|
||||
{missingRequired.map((d) => d.label).join(", ")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Required customer documents */}
|
||||
<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 &&
|
||||
isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{doc.file && (
|
||||
<IconSquare
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
)}
|
||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
||||
<FileButton
|
||||
onChange={(f) => f && stagePending(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>
|
||||
))}
|
||||
{customerDocs.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
No clearance documents are configured for this contract yet.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* GL output documents (read-only). */}
|
||||
{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 ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IconSquare
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
disabled={!canSubmit}
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={submitDocuments}
|
||||
>
|
||||
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
{id && <ContractClearancePanel contractId={id} />}
|
||||
</Stack>
|
||||
{viewer}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Plus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
||||
|
||||
const GREEN = "#0A6F4D";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
type AdHocDoc = { name: string; file: File | null };
|
||||
|
||||
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
|
||||
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">
|
||||
Query
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (doc.file) {
|
||||
return (
|
||||
<Text fz="12px" fw={600} c="#6B7C8E">
|
||||
Uploaded
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ContractClearancePanelProps {
|
||||
contractId: string;
|
||||
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clearance document workspace for a contract: shows the customer-input doc
|
||||
* grid + GL output docs, lets the customer upload / re-upload (only queried docs
|
||||
* after first submission), and surfaces query notes. Rendered both on the
|
||||
* standalone clearance page and inside the contract action modal. See
|
||||
* docs/new-doc.md §8.3.
|
||||
*/
|
||||
export function ContractClearancePanel({
|
||||
contractId,
|
||||
bare,
|
||||
}: ContractClearancePanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const clearanceQuery = useQuery(
|
||||
api.contracts.getClearance.queryOptions({
|
||||
input: { id: contractId },
|
||||
enabled: !!contractId,
|
||||
}),
|
||||
);
|
||||
const clearance = clearanceQuery.data;
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
...api.contracts.uploadClearanceDocuments.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
setPending({});
|
||||
setAdHoc([]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.getClearance.queryKey({ id: contractId }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: contractId }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
|
||||
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
||||
const isReady =
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
status === "SELF_CLEARED" ||
|
||||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||
|
||||
const customsPath = clearance?.includesCustoms ?? true;
|
||||
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
||||
[customerDocs, pending],
|
||||
);
|
||||
|
||||
const hasStagedFiles =
|
||||
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
|
||||
|
||||
const canSubmit = isInitialUpload
|
||||
? hasStagedFiles && missingRequired.length === 0
|
||||
: hasStagedFiles;
|
||||
|
||||
const stagePending = (fileKey: string, file: File) =>
|
||||
setPending((p) => ({ ...p, [fileKey]: file }));
|
||||
|
||||
const submitDocuments = () => {
|
||||
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: contractId, files });
|
||||
};
|
||||
|
||||
if (clearanceQuery.isLoading) {
|
||||
return (
|
||||
<Center mih={bare ? 200 : 400} p="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const body = (
|
||||
<Stack gap={0}>
|
||||
{isReady ? (
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||
{customsPath
|
||||
? "Your clearance documents are approved. Global Logistics will create your booking — you will be notified when payment is due."
|
||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||
</Alert>
|
||||
) : isUnderReview ? (
|
||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is reviewing
|
||||
your documents. Only re-upload the documents flagged with a query
|
||||
below — approved documents stay as they are.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
||||
{customsPath
|
||||
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
|
||||
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isInitialUpload && missingRequired.length > 0 && (
|
||||
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
||||
<Text fz="12px" c="#9A5B00">
|
||||
Still required: {missingRequired.map((d) => d.label).join(", ")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Required customer documents */}
|
||||
<Stack gap={10}>
|
||||
{customerDocs.map((doc) => (
|
||||
<Box
|
||||
key={doc.fileKey}
|
||||
className="rounded-xl"
|
||||
style={{ border: `1px solid ${BORDER}`, 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 &&
|
||||
isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{doc.file && (
|
||||
<IconSquare
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
)}
|
||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
||||
<FileButton
|
||||
onChange={(f) => f && stagePending(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>
|
||||
))}
|
||||
{customerDocs.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
No clearance documents are configured for this contract yet.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* GL output documents (read-only). */}
|
||||
{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 ${BORDER}`, padding: 10 }}
|
||||
>
|
||||
<Text fz="13px" c="#10202F" truncate>
|
||||
{doc.label}
|
||||
</Text>
|
||||
{doc.file ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IconSquare
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
disabled={!canSubmit}
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={submitDocuments}
|
||||
>
|
||||
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (bare) return body;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
|
||||
{body}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContractClearancePanel;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
@@ -36,10 +36,15 @@ import {
|
||||
Upload,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
import { Modal } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { isViewable, type ViewableFile } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import {
|
||||
BORDER,
|
||||
@@ -63,11 +68,66 @@ const CLEARANCE_UPLOAD_STATUSES = [
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||
|
||||
// Onboarding / company-profile document codes seeded in file-upload-settings.
|
||||
// These get attached to the contract at creation and belong under "Profile
|
||||
// documents" rather than the clearance set.
|
||||
const PROFILE_DOC_CODES = new Set([
|
||||
"tin_certificate",
|
||||
"commercial_license",
|
||||
"business_license",
|
||||
"investment_license",
|
||||
"national_id",
|
||||
"national_id_passport",
|
||||
"passport",
|
||||
]);
|
||||
|
||||
interface DocGroup {
|
||||
key: string;
|
||||
title: string;
|
||||
files: ContractFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a contract's files into display sections by their `code`: the generated
|
||||
* contract PDF, company profile / onboarding documents, and clearance documents
|
||||
* (everything else — the clearance set uses dynamic per-contract codes). Empty
|
||||
* groups are dropped so the tab only renders sections that have files.
|
||||
*/
|
||||
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
||||
const contract: ContractFile[] = [];
|
||||
const profile: ContractFile[] = [];
|
||||
const clearance: ContractFile[] = [];
|
||||
for (const f of files) {
|
||||
if (f.code === "contract") contract.push(f);
|
||||
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
||||
else clearance.push(f);
|
||||
}
|
||||
return [
|
||||
{ key: "contract", title: "Contract document", files: contract },
|
||||
{ key: "profile", title: "Profile documents", files: profile },
|
||||
{ key: "clearance", title: "Clearance documents", files: clearance },
|
||||
].filter((g) => g.files.length > 0);
|
||||
}
|
||||
|
||||
export default function ContractDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<string>("details");
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [clearanceOpen, clearanceModal] = useDisclosure(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Deep link from the home "needs attention" card: /contracts/:id?action=clearance
|
||||
// opens the clearance step modal directly.
|
||||
useEffect(() => {
|
||||
if (searchParams.get("action") === "clearance") {
|
||||
clearanceModal.open();
|
||||
searchParams.delete("action");
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}
|
||||
}, [searchParams, clearanceModal, setSearchParams]);
|
||||
|
||||
const {
|
||||
data: contract,
|
||||
@@ -126,6 +186,7 @@ export default function ContractDetailPage() {
|
||||
const routes = contract.routes ?? [];
|
||||
const pricing = contract.pricingBreakdown;
|
||||
const files = contract.files ?? [];
|
||||
const docGroups = groupContractDocuments(files);
|
||||
|
||||
const canSign = contract.status === "CONTRACT_READY";
|
||||
const customsPath = contract.customsClearingEnabled;
|
||||
@@ -197,9 +258,11 @@ export default function ContractDetailPage() {
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => navigate(`/contracts/${contract.id}/clearance`)}
|
||||
onClick={clearanceModal.open}
|
||||
>
|
||||
Upload clearance documents
|
||||
{contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "Manage clearance documents"
|
||||
: "Upload clearance documents"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -610,74 +673,23 @@ export default function ContractDetailPage() {
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={10}>
|
||||
{files.map((file) => (
|
||||
<Group
|
||||
key={file.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText
|
||||
size={16}
|
||||
color={MUTED}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
fz={14}
|
||||
fw={600}
|
||||
style={{ color: INK }}
|
||||
truncate
|
||||
>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{file.mimeType?.split("/")[1]?.toUpperCase() ??
|
||||
"FILE"}
|
||||
{file.size
|
||||
? ` · ${(file.size / 1024).toFixed(0)} KB`
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<Stack gap="lg">
|
||||
{docGroups.map((group) => (
|
||||
<Stack key={group.key} gap={8}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fz={12} fw={700} c="dimmed" tt="uppercase">
|
||||
{group.title}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{group.files.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: file.name,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
}) && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: file.name,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
component="a"
|
||||
href={fileViewUrl(file.id, true)}
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Download size={14} />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap={10}>
|
||||
{group.files.map((file) => (
|
||||
<DocFileRow key={file.id} file={file} onView={view} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -771,6 +783,22 @@ export default function ContractDetailPage() {
|
||||
</Tabs>
|
||||
</Stack>
|
||||
{viewer}
|
||||
|
||||
<Modal
|
||||
opened={clearanceOpen}
|
||||
onClose={clearanceModal.close}
|
||||
title={
|
||||
<Text fw={700} fz={16}>
|
||||
Clearance documents
|
||||
</Text>
|
||||
}
|
||||
size="xl"
|
||||
radius="md"
|
||||
centered
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
<ContractClearancePanel contractId={contract.id} bare />
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -863,6 +891,77 @@ function SectionLabel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One document row in the Documents tab: the file's kind (passport, business
|
||||
* license, contract, …) derived from its `code` as the primary label, the
|
||||
* stored filename and size as secondary text, and view / download actions that
|
||||
* stream through the API by file id.
|
||||
*/
|
||||
function DocFileRow({
|
||||
file,
|
||||
onView,
|
||||
}: {
|
||||
file: ContractFile;
|
||||
onView: (f: ViewableFile) => void;
|
||||
}) {
|
||||
const kind = labelForDocCode(file.code);
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={16} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={600} style={{ color: INK }} truncate>
|
||||
{kind}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
{file.name}
|
||||
{file.size ? ` · ${(file.size / 1024).toFixed(0)} KB` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: file.name,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
}) && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: file.name,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
component="a"
|
||||
href={fileViewUrl(file.id, true)}
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Download size={14} />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One fact inside the unified key-facts card: a soft accent-tinted icon chip,
|
||||
* an uppercase label, and the value. Hairline dividers between cells make the
|
||||
|
||||
Reference in New Issue
Block a user