mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
349 lines
11 KiB
TypeScript
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;
|