Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx

769 lines
25 KiB
TypeScript

import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
FileButton,
Group,
Loader,
Paper,
Progress,
Stack,
Text,
Textarea,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
Eye,
FileCheck2,
FileText,
MessageSquareWarning,
Upload,
UserCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useFileViewer } from "@/hooks/useFileViewer";
export interface ContractClearanceReviewSectionProps {
contractId: string;
/** Called after any review/finalize mutation so the parent can refetch. */
onChanged?: () => void;
/** Hide the inline progress summary (e.g. when the parent renders its own). */
hideSummary?: boolean;
/**
* Path A (non-customs): the reviewer is Operations, not GL, and there is no GL
* output upload step. Routes review/finalize to the Operations endpoints.
*/
selfClear?: boolean;
/**
* Clearance is finalized — render the document outcomes (approved / queried,
* by whom, when) but hide all approve / query / finalize actions.
*/
readOnly?: boolean;
/**
* Document approvals are locked (e.g. after all docs approved in phased flow)
* but queries remain available until {@link queriesLocked} or {@link readOnly}.
*/
approvalsLocked?: boolean;
/**
* Pre-clearance finalized — block opening new queries on customer documents.
*/
queriesLocked?: boolean;
/**
* ONE_TIME customs contracts use the phased milestone workflow. Hides the
* legacy "Finalize document approval" shortcut; booking readiness follows delivery
* order (import) or export release.
*/
phasedCustoms?: boolean;
}
const STATUS_META: Record<
Freight.ContractDocReviewStatus,
{ label: string; color: string }
> = {
APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "gray" },
};
function formatReviewedAt(value?: string | null): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Pre-booking clearance review for a CONTRACT. Approve / query each customer
* document, upload GL output documents, and finalize once every required
* document is approved. When `readOnly` it becomes an audit view: approved /
* queried outcomes with reviewer + timestamp, no actions.
*/
export function ContractClearanceReviewSection({
contractId,
onChanged,
hideSummary,
selfClear = false,
readOnly = false,
phasedCustoms = false,
approvalsLocked = false,
queriesLocked = false,
}: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const { view, viewer } = useFileViewer();
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
const { data: clearance, isLoading } = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
queryFn: () => contractsService.getClearance(contractId),
});
const { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance } =
useContractClearanceMutations(contractId, selfClear);
const customerDocs = useMemo(
() =>
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
// GL output documents — anything not uploaded by the customer. The backend
// tags these `uploadedBy: 'gl'`; matching on "not customer" keeps it robust if
// that ever splits into gl_et / gl_dj.
const glDocs = useMemo(
() =>
(clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
[clearance],
);
const stats = useMemo(() => {
const total = customerDocs.length;
const approved = customerDocs.filter(
(d) => d.reviewStatus === "APPROVED",
).length;
const queried = customerDocs.filter(
(d) => d.reviewStatus === "QUERIED",
).length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [customerDocs]);
// Documents with a file uploaded but not yet approved — "Approve all" targets.
const approvableKeys = customerDocs
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
.map((d) => d.fileKey);
const hasDocsAwaitingApproval = customerDocs.some(
(d) => d.file && d.reviewStatus !== "APPROVED",
);
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
if (isLoading || !clearance) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
);
}
const handleReview = (
fileKey: string,
status: "APPROVED" | "QUERIED",
note?: string,
) =>
reviewDocument.mutate(
{ fileKey, status, note },
{
onSuccess: () => {
if (status === "QUERIED")
setOpenQuery((o) => ({ ...o, [fileKey]: false }));
onChanged?.();
},
},
);
return (
<Stack gap="lg">
<SectionCard
icon={FileText}
title="Customer documents"
subtitle={
readOnly
? `Reviewed by the ${reviewerTeam} team.`
: effectiveApprovalsLocked
? "Documents are approved — you can still open a query if something needs fixing."
: "Approve each document, or open a query to tell the customer what to fix."
}
extra={
<Group gap={10} wrap="nowrap" align="center">
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
{!readOnly && !effectiveApprovalsLocked && approvableKeys.length > 0 && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<FileCheck2 size={14} />}
loading={approveAll.isPending}
disabled={reviewDocument.isPending}
onClick={() => approveAll.mutate(approvableKeys)}
>
Approve all ({approvableKeys.length})
</Button>
)}
</Group>
}
>
<Stack gap={12}>
{!hideSummary && stats.total > 0 && (
<Box>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed">
No customer documents are required for this contract.
</Text>
) : (
customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
reviewerTeam={reviewerTeam}
readOnly={readOnly}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) =>
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() => handleReview(doc.fileKey, "APPROVED")}
onQuery={() =>
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
}
onView={view}
busy={reviewDocument.isPending}
/>
))
)}
</Stack>
</SectionCard>
{glDocs.length > 0 && !phasedCustoms && (
<SectionCard
icon={Upload}
title="GL output documents"
subtitle="Upload each document individually — changes save immediately."
accent="edr-green"
>
<Stack gap={10}>
{glDocs.map((doc) => {
const isUploading =
uploadingKey === doc.fileKey && uploadOutputDocuments.isPending;
return (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
<Text fz="13px" c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
</Group>
<Group gap={8} wrap="nowrap">
{doc.file ? (
<>
{isViewable({
name: doc.file.name,
url: "",
}) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
void fetchViewableFile(
doc.file!.id,
doc.file!.name,
).then(view)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() =>
void downloadBookingFile(
doc.file!.id,
doc.file!.name,
)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Download size={15} />
</Box>
</Tooltip>
</>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
</Text>
)}
{!readOnly && (
<FileButton
onChange={(f) => {
if (!f) return;
setUploadingKey(doc.fileKey);
uploadOutputDocuments.mutate(
{ [doc.fileKey]: f },
{ onSuccess: () => { setUploadingKey(null); onChanged?.(); },
onError: () => setUploadingKey(null) },
);
}}
accept="application/pdf,image/*"
disabled={isUploading}
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={
isUploading ? (
<Loader size={12} color="edr-green" />
) : (
<Upload size={13} />
)
}
loading={isUploading}
>
{doc.file ? "Replace" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
);
})}
</Stack>
</SectionCard>
)}
{!readOnly && finalizeClearance.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeClearance.error instanceof Error
? finalizeClearance.error.message
: "Could not finalize document approval."}
</Alert>
)}
{readOnly ? (
<Paper
withBorder
radius="md"
p="md"
style={{
borderColor: "var(--mantine-color-edr-green-3)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 70%)",
}}
>
<Group gap={10} wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
<CheckCircle2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{phasedCustoms
? "Document review is complete. Continue customs milestones in the action panel."
: `Clearance was finalized by the ${reviewerTeam} team. This is a read-only record of the approved documents.`}
</Text>
</Group>
</Paper>
) : phasedCustoms && effectiveApprovalsLocked ? (
<Paper withBorder radius="md" p="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
<CheckCircle2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
Document review is complete. Use the action panel for declaration, duty, and
transit steps
{queriesLocked
? ". Pre-clearance is finalized — customer documents can no longer be queried."
: " — or open a query above if a customer document needs correction."}
</Text>
</Group>
</Paper>
) : phasedCustoms ? (
<Paper withBorder radius="md" p="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
: "Approve every required document to unlock the customs milestone steps."}
</Text>
</Group>
</Paper>
) : (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeClearance.isPending}
onClick={() =>
finalizeClearance.mutate(undefined, {
onSuccess: () => onChanged?.(),
})
}
>
Finalize document approval
</Button>
</Group>
</Paper>
)}
{viewer}
</Stack>
);
}
function StatPill({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Group gap={6} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="12.5px" c="edr-text" fw={600}>
{value}
</Text>
<Text fz="12.5px" c="dimmed">
{label}
</Text>
</Group>
);
}
function DocReviewCard({
doc,
reviewerTeam,
readOnly,
approvalsLocked,
queriesLocked,
note,
queryOpen,
onToggleQuery,
onNote,
onApprove,
onQuery,
onView,
busy,
}: {
doc: Freight.ContractClearanceDocument;
reviewerTeam: string;
readOnly: boolean;
approvalsLocked: boolean;
queriesLocked: boolean;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
onNote: (v: string) => void;
onApprove: () => void;
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const hasFile = !!doc.file;
const isApproved = status === "APPROVED";
const isQueried = status === "QUERIED";
const reviewedAt = formatReviewedAt(doc.reviewedAt);
// Approved cards get a light green gradient + green border so the outcome is
// instantly scannable; queried cards get a soft red; pending stay neutral.
const cardStyle = isApproved
? {
borderColor: "var(--mantine-color-edr-green-3)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 72%)",
}
: isQueried
? {
borderColor: "var(--mantine-color-red-2)",
background:
"linear-gradient(135deg, var(--mantine-color-red-0) 0%, #FFFFFF 78%)",
}
: { borderColor: "var(--mantine-color-edr-border-6)" };
return (
<Paper withBorder radius="md" p="md" style={cardStyle}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={isApproved ? "edr-green" : isQueried ? "red" : "gray"}
radius="md"
size={40}
>
{isApproved ? <CheckCircle2 size={19} /> : <FileText size={19} />}
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
{isApproved && (reviewedAt || reviewerTeam) && (
<Group gap={5} wrap="nowrap" mt={3}>
<UserCheck size={12} color="var(--mantine-color-edr-green-7)" />
<Text fz="11.5px" c="edr-green.8" fw={600} truncate>
Approved by {reviewerTeam}
{reviewedAt ? ` · ${reviewedAt}` : ""}
</Text>
</Group>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Badge
variant="light"
color={meta.color}
radius="sm"
leftSection={
isApproved ? (
<CheckCircle2 size={11} />
) : isQueried ? (
<MessageSquareWarning size={11} />
) : (
<Clock size={11} />
)
}
>
{meta.label}
</Badge>
{hasFile &&
isViewable({
name: doc.file!.name,
url: "",
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
onView,
)
}
>
View
</Button>
</Tooltip>
)}
{hasFile && (
<Tooltip label="Download">
<Button
component="button"
type="button"
onClick={() =>
void downloadBookingFile(doc.file!.id, doc.file!.name)
}
size="compact-xs"
variant="default"
radius="md"
leftSection={<Download size={13} />}
>
Download
</Button>
</Tooltip>
)}
</Group>
</Group>
{isQueried && doc.note && (
<Alert
mt="sm"
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
>
<Text fz="12.5px" c="red.9">
{doc.note}
</Text>
</Alert>
)}
{!readOnly && hasFile && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
{!queriesLocked && (
<Button
size="sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={15} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
)}
{!isApproved && !approvalsLocked && (
<Button
size="sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
</Group>
) : (
<Box
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<Group gap={6} mb={6}>
<MessageSquareWarning
size={14}
color="var(--mantine-color-red-7)"
/>
<Text fz="12.5px" fw={700} c="red.8">
Describe the problem for the customer
</Text>
</Group>
<Textarea
placeholder="e.g. The commercial invoice is missing the HS code."
value={note}
onChange={(e) => onNote(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
size="sm"
autoFocus
/>
<Group justify="flex-end" gap={8} mt={8}>
<Button
size="sm"
variant="subtle"
color="gray"
radius="md"
disabled={busy}
onClick={() => onToggleQuery(false)}
>
Cancel
</Button>
<Button
size="sm"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={15} />}
loading={busy}
disabled={!note.trim()}
onClick={onQuery}
>
Send query to customer
</Button>
</Group>
</Box>
)}
</Box>
)}
</Paper>
);
}