import { useState } from "react"; import { ActionIcon, Alert, Badge, Button, Checkbox, Drawer, Group, Paper, Progress, Stack, Text, TextInput, Tooltip, } from "@mantine/core"; import { IconAlertCircle, IconCheck, IconDownload, IconEye, IconFileText, IconRotate, IconX, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { conditionHolds, useClearDocumentReviewMutation, useGetDocumentReviewsQuery, useLocalized, useReviewDocumentMutation, type Attachment, type DocumentRequirement, } from "@ema-platform/api"; import { notifications } from "@mantine/notifications"; interface DocumentsTabProps { applicationId: string; attachments: Attachment[]; /** From the licence type config, so completeness is measured against rules. */ requirements: DocumentRequirement[]; /** Applicant answers used to evaluate conditional document requirements. */ formData: Record>; /** documentKey -> remark. Owned by the review page. */ flags: Record; onToggleFlag: (documentKey: string) => void; onFlagRemark: (documentKey: string, remark: string) => void; } /** * The reviewer's document workspace. * * Previously a list with View and Download buttons that had no handlers at * all — the officer could see that a document existed but not what was in it, * which makes "approve documents" an act of faith. This previews inline, * measures what is uploaded against what the licence type requires, and lets * each document be flagged with its own reason. */ export function DocumentsTab({ applicationId, attachments, requirements, formData, flags, onToggleFlag, onFlagRemark, }: DocumentsTabProps) { const { t } = useTranslation(); const localized = useLocalized(); const [preview, setPreview] = useState(null); const [rejecting, setRejecting] = useState>({}); // Verdicts are persisted per document, so an accept survives a reload and // is visible to whoever picks the application up next. const { data: reviews = [] } = useGetDocumentReviewsQuery(applicationId, { skip: !applicationId, }); const [reviewDocument, { isLoading: saving }] = useReviewDocumentMutation(); const [clearReview] = useClearDocumentReviewMutation(); const verdictFor = (documentKey: string) => reviews.find((review) => review.documentKey === documentKey); async function decide( documentKey: string, decision: "ACCEPTED" | "REJECTED", attachmentId?: string, ) { const reason = rejecting[documentKey]?.trim(); if (decision === "REJECTED" && !reason) { // The applicant is shown this verbatim, so refuse to send an empty one. notifications.show({ color: "red", title: t("review.documents.reasonRequired", "A reason is required"), message: "", }); return; } try { await reviewDocument({ id: applicationId, documentKey, decision, reason: decision === "REJECTED" ? reason : undefined, attachmentId, }).unwrap(); setRejecting((prev) => { const next = { ...prev }; delete next[documentKey]; return next; }); } catch { notifications.show({ color: "red", title: t("review.documents.saveFailed", "Could not save the verdict"), message: "", }); } } const uploadedKeys = new Set(attachments.map((a) => a.documentKey)); const requirementByKey = new Map(requirements.map((r) => [r.key, r])); const mandatory = requirements.filter( (r) => r.mode === "ALWAYS" || (r.mode === "CONDITIONAL" && conditionHolds(r.conditionExpression, formData)), ); const missing = mandatory.filter((r) => !uploadedKeys.has(r.key)); const completeness = mandatory.length ? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100) : 100; const previewFile = preview?.files?.[0]; const isImage = previewFile?.mimeType?.startsWith("image/"); const isPdf = previewFile?.mimeType === "application/pdf"; return ( {/* Completeness against the licence type's own requirement list. */} {t("review.documents.completeness", "Required documents")} {mandatory.length - missing.length}/{mandatory.length} {missing.length > 0 && ( } variant="light" > {t("review.documents.missing", "Not yet uploaded")}:{" "} {missing.map((r) => localized(r.name) || r.key).join(", ")} )} {attachments.map((attachment) => { const file = attachment.files?.[0]; const flagged = attachment.documentKey in flags; const verdict = verdictFor(attachment.documentKey); const pendingReject = attachment.documentKey in rejecting; return (
{/* Badges sit beside the name, not in the outer nowrap row — that row also has to fit six action buttons, so a long name plus badges there overflowed its box and the opaque badge painted over the bleeding text. Nested here with its own wrap, the name truncates cleanly instead. */} {localized( requirementByKey.get(attachment.documentKey)?.name, ) || attachment.documentKey} {verdict && ( ) : ( ) } > {verdict.decision === "ACCEPTED" ? t("review.documents.accepted", "Accepted") : t("review.documents.rejected", "Rejected")} )} {flagged && ( {t("review.documents.flagged", "Correction requested")} )} {file?.originalName ?? t("review.documents.noFile", "No file")}
{/* Accept / Reject are the officer's own record of having checked the file, persisted independently of any adjustment round. */} decide( attachment.documentKey, "ACCEPTED", attachment.id, ) } > setRejecting((prev) => ({ ...prev, [attachment.documentKey]: verdict?.reason ?? "", })) } > {verdict && ( clearReview({ id: applicationId, documentKey: attachment.documentKey, }) } > )} onToggleFlag(attachment.documentKey)} label={t("review.documents.includeInAdjustment", "Send back")} />
{pendingReject && ( { // Read before the updater: React nulls `currentTarget` // once the handler returns, and the updater runs later, // during the re-render. const reason = e.currentTarget.value; setRejecting((prev) => ({ ...prev, [attachment.documentKey]: reason, })); }} /> )} {flagged && ( onFlagRemark(attachment.documentKey, e.currentTarget.value) } error={ flags[attachment.documentKey].trim() ? undefined : t( "review.documents.reasonRequired", "A reason is required", ) } /> )}
); })} setPreview(null)} position="right" size="xl" title={ preview ? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey : "" } // Focus is trapped and returned so keyboard users are not dropped at // the top of the page when the drawer closes. trapFocus returnFocus > {previewFile?.url ? ( isPdf ? (