Files
edr-platform/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx
2026-07-02 11:09:25 +00:00

349 lines
11 KiB
TypeScript

import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
Eye,
Upload,
} from "lucide-react";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import {
ClearanceAdHocUploadSection,
type AdHocDoc,
} from "@/components/contracts/ClearanceAdHocUploadSection";
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { useFileViewer } from "@/hooks/useFileViewer";
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
const BORDER = "#E6ECF2";
export interface ContractClearancePanelProps {
contractId: string;
tradeDirection?: 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,
tradeDirection = "IMPORT",
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(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
// Surface queried documents (the ones needing correction) first.
const rank = (s: string | null) =>
s === "QUERIED" ? 0 : s === "APPROVED" ? 2 : 1;
return [...docs].sort(
(a, b) => rank(a.reviewStatus) - rank(b.reviewStatus),
);
}, [clearance]);
const queriedCount = useMemo(
() => customerDocs.filter((d) => d.reviewStatus === "QUERIED").length,
[customerDocs],
);
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 | null) =>
setPending((p) => {
if (file) return { ...p, [fileKey]: file };
const next = { ...p };
delete next[fileKey];
return next;
});
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}>
{queriedCount > 0 && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={18} />}
mb="md"
title={`${queriedCount} document${queriedCount > 1 ? "s" : ""} need correction`}
>
Re-upload the highlighted document{queriedCount > 1 ? "s" : ""} below to
continue. The reviewer's note explains what to fix.
</Alert>
)}
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{customsPath
? "Your clearance documents are approved. Global Logistics will create your booking on your behalf — 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="md">
<Box>
<Text fz={13} fw={700} c="#10202F">
Your clearance documents
</Text>
<Text fz={12} c="dimmed" mt={4}>
Upload each required document below. Items marked * are mandatory.
</Text>
</Box>
{customerDocs.map((doc) => (
<ClearanceDocumentUploadCard
key={doc.fileKey}
label={doc.label}
required={doc.required}
reviewStatus={doc.reviewStatus}
note={doc.note}
uploadedFile={doc.file}
stagedFile={pending[doc.fileKey] ?? null}
canUpload={canUpload}
onStageFile={
canUpload && doc.reviewStatus !== "APPROVED"
? (file) => stagePending(doc.fileKey, file)
: undefined
}
onPreview={view}
/>
))}
{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>
</>
)}
<Box mt="lg">
<ClearanceUploadedDocumentsPanel
embedded
tradeDirection={tradeDirection}
files={clearance?.workflowFiles ?? []}
title="Customs workflow documents"
onView={(f) => view(f)}
onDownload={({ id, name }) => {
const a = document.createElement("a");
a.href = fileViewUrl(id, true);
a.download = name;
a.click();
}}
/>
</Box>
{canUpload ? (
<ClearanceAdHocUploadSection
rows={adHoc}
onAdd={() => setAdHoc((r) => [...r, { name: "", file: null }])}
onRemove={(i) => setAdHoc((rows) => rows.filter((_, j) => j !== i))}
onNameChange={(i, name) =>
setAdHoc((rows) => rows.map((r, j) => (j === i ? { ...r, name } : r)))
}
onFileChange={(i, file) =>
setAdHoc((rows) => rows.map((r, j) => (j === i ? { ...r, file } : r)))
}
onPreview={view}
/>
) : null}
{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="xl">
<Button
color="edr-green"
radius="md"
size="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;