Files
emaui/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx

448 lines
16 KiB
TypeScript

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<string, string>;
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<Attachment | null>(null);
const [rejecting, setRejecting] = useState<Record<string, string>>({});
// 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 (
<Stack gap="md">
{/* Completeness against the licence type's own requirement list. */}
<Paper withBorder p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
{t('review.documents.completeness', 'Required documents')}
</Text>
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
{mandatory.length - missing.length}/{mandatory.length}
</Text>
</Group>
<Progress
value={completeness}
color={missing.length ? 'orange' : 'teal'}
aria-label={t('review.documents.completenessLabel', {
value: completeness,
defaultValue: '{{value}}% of required documents uploaded',
})}
/>
{missing.length > 0 && (
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
<Text size="sm">
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
{missing.map((r) => localized(r.name) || r.key).join(', ')}
</Text>
</Alert>
)}
</Paper>
{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 (
<Paper withBorder p="md" key={attachment.id}>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<IconFileText size={20} stroke={1.6} />
<div style={{ minWidth: 0, flex: 1 }}>
{/* 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. */}
<Group gap={6} wrap="wrap" align="center">
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
{localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
</Text>
{verdict && (
<Tooltip
label={
verdict.reason ??
t('review.documents.reviewedBy', {
name: verdict.reviewedByName ?? '—',
defaultValue: 'Reviewed by {{name}}',
})
}
>
<Badge
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
variant="light"
size="sm"
leftSection={
verdict.decision === 'ACCEPTED' ? (
<IconCheck size={11} />
) : (
<IconX size={11} />
)
}
>
{verdict.decision === 'ACCEPTED'
? t('review.documents.accepted', 'Accepted')
: t('review.documents.rejected', 'Rejected')}
</Badge>
</Tooltip>
)}
{flagged && (
<Badge color="orange" variant="light" size="sm">
{t('review.documents.flagged', 'Correction requested')}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
file?.url
? t('review.documents.preview', 'Preview')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
}
>
<span>
<Button
size="compact-sm"
variant="light"
leftSection={<IconEye size={14} />}
disabled={!file?.url}
onClick={() => setPreview(attachment)}
>
{t('review.documents.view', 'View')}
</Button>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.download', 'Download')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
}
>
<span>
<ActionIcon
variant="subtle"
disabled={!file?.url}
component="a"
href={file?.url}
download={file?.originalName}
target="_blank"
rel="noreferrer"
aria-label={t('review.documents.download', 'Download')}
>
<IconDownload size={16} />
</ActionIcon>
</span>
</Tooltip>
{/* Accept / Reject are the officer's own record of having
checked the file, persisted independently of any
adjustment round. */}
<Tooltip
label={
file?.url
? t('review.documents.accept', 'Accept')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
color="teal"
loading={saving}
disabled={!file?.url}
aria-label={t('review.documents.accept', 'Accept')}
onClick={() =>
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
}
>
<IconCheck size={16} />
</ActionIcon>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.reject', 'Reject')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
color="red"
disabled={!file?.url}
aria-label={t('review.documents.reject', 'Reject')}
onClick={() =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: verdict?.reason ?? '',
}))
}
>
<IconX size={16} />
</ActionIcon>
</span>
</Tooltip>
{verdict && (
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t('review.documents.clear', 'Clear verdict')}
onClick={() =>
clearReview({
id: applicationId,
documentKey: attachment.documentKey,
})
}
>
<IconRotate size={16} />
</ActionIcon>
</Tooltip>
)}
<Checkbox
size="xs"
checked={flagged}
onChange={() => onToggleFlag(attachment.documentKey)}
label={t('review.documents.includeInAdjustment', 'Send back')}
/>
</Group>
</Group>
{pendingReject && (
<Group mt="sm" gap="xs" align="flex-start" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
size="xs"
autoFocus
placeholder={t(
'review.documents.rejectReason',
'Why must this document be corrected?',
)}
value={rejecting[attachment.documentKey]}
onChange={(e) => {
// 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,
}));
}}
/>
<Button
size="compact-sm"
color="red"
loading={saving}
disabled={!rejecting[attachment.documentKey]?.trim()}
onClick={() =>
decide(attachment.documentKey, 'REJECTED', attachment.id)
}
>
{t('review.documents.confirmReject', 'Reject')}
</Button>
</Group>
)}
{flagged && (
<TextInput
mt="sm"
size="xs"
placeholder={t(
'review.documents.adjustmentNote',
'What must the applicant correct?',
)}
value={flags[attachment.documentKey]}
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
error={
flags[attachment.documentKey].trim()
? undefined
: t('review.documents.reasonRequired', 'A reason is required')
}
/>
)}
</Paper>
);
})}
<Drawer
opened={Boolean(preview)}
onClose={() => 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 ? (
<iframe
src={previewFile.url}
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ width: '100%', height: '80vh', border: 'none' }}
/>
) : isImage ? (
<img
src={previewFile.url}
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ maxWidth: '100%' }}
/>
) : (
// Anything the browser will not render inline still gets a way out.
<Stack align="center" gap="sm" py="xl">
<Text size="sm" c="dimmed">
{t(
'review.documents.noInlinePreview',
'This file type cannot be previewed in the browser.',
)}
</Text>
<Button
component="a"
href={previewFile.url}
target="_blank"
rel="noreferrer"
leftSection={<IconDownload size={16} />}
>
{t('review.documents.downloadShort', 'Download')}
</Button>
</Stack>
)
) : null}
</Drawer>
</Stack>
);
}