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 { 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[]; /** 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, 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 !== 'OPTIONAL'); 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 ? (