import { useRef, useState } from 'react'; import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text, } from '@mantine/core'; import { IconAlertTriangle, IconCheck, IconFileUpload, } from '@tabler/icons-react'; import { conditionHolds, useLocalized, uploadDocument, type Attachment, type DocumentRequirement, } from '@ema-platform/api'; import { useTranslation } from 'react-i18next'; import { FilePreviewModal } from '@ema-platform/ui'; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; interface Props { requirements: DocumentRequirement[]; attachments: Attachment[]; formData: Record>; ownerType: 'APPLICATION' | 'APPLICATION_STAFF'; ownerId: string; /** Document keys the officer flagged; these render as "needs fixing". */ flagged?: Record; /** When set, only flagged slots accept a new upload. */ restrictToFlagged?: boolean; /** * Requirement keys opened because a flagged section drives their condition โ€” * a category correction can make documents newly required, and those have to * be uploadable even though the officer flagged no document. */ alsoUnlocked?: string[]; onUploaded: () => void; readOnly?: boolean; } /** * The upload slots for an application, driven by the configured document * requirements. Conditional slots appear only when their rule matches the * answers given โ€” an owned vehicle asks for a libre, a rented one for the * rental agreement. */ export function DocumentSlots({ requirements, attachments, formData, ownerType, ownerId, flagged = {}, restrictToFlagged = false, alsoUnlocked = [], onUploaded, readOnly, }: Props) { const localized = useLocalized(); const { t } = useTranslation(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [preview, setPreview] = useState<{ url: string; title: string; mimeType?: string | null; } | null>( null, ); const resetRefs = useRef void>>({}); const required = requirements.filter( (r) => r.mode === 'ALWAYS' || r.mode === 'OPTIONAL' || (r.mode === 'CONDITIONAL' && conditionHolds(r.conditionExpression, formData)), ); async function handle(documentKey: string, file: File | null) { if (!file) return; if (file.size > MAX_FILE_SIZE_BYTES) { setError( t('licensing.msg.fileTooLarge', { size: `${(file.size / 1024 / 1024).toFixed(1)}MB`, }), ); resetRefs.current[documentKey]?.(); return; } setBusy(documentKey); setError(null); const result = await uploadDocument({ ownerType, ownerId, documentKey, file }); setBusy(null); resetRefs.current[documentKey]?.(); if (result.ok) onUploaded(); else setError(result.error); } return ( {error && ( }> {error} )} {required.map((requirement) => { const existing = attachments.find((a) => a.documentKey === requirement.key); const uploaded = Boolean(existing?.files?.length); const files = existing?.files ?? []; const fromVault = Boolean(existing?.copiedFromAttachmentId); const flagRemark = flagged[requirement.key]; const locked = readOnly || (restrictToFlagged && !flagRemark && !alsoUnlocked.includes(requirement.key)); return (
{localized(requirement.name)} {requirement.mode === 'CONDITIONAL' && ( {t('licensing.documents.conditional')} )} {requirement.mode === 'OPTIONAL' && ( {t('common.optional')} )} {uploaded && !flagRemark && ( }> {t('licensing.documents.uploaded')} )} {/* Filled from the applicant's own vault rather than uploaded here โ€” without saying so, a file they never attached to this application looks like a mistake. */} {fromVault && !flagRemark && ( {t('licensing.documents.fromVault')} )} {requirement.description && ( {localized(requirement.description)} )} {files.map((file) => ( {file.originalName} ยท {(Number(file.sizeBytes) / 1024).toFixed(0)} KB ))} {flagRemark && ( {t('licensing.documents.officerRemark', { name: flagRemark })} )}
{files .filter((file) => file.url) .map((file, index) => ( ))} {!locked && ( { if (r) resetRefs.current[requirement.key] = r; }} onChange={(file) => handle(requirement.key, file)} accept={requirement.allowedMimeTypes?.join(',')} > {(props) => ( )} )}
); })} setPreview(null)} url={preview?.url ?? ''} title={preview?.title} mimeType={preview?.mimeType} />
); }