Files
emaui/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx

253 lines
8.2 KiB
TypeScript

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<string, Record<string, unknown>>;
ownerType: 'APPLICATION' | 'APPLICATION_STAFF';
ownerId: string;
/** Document keys the officer flagged; these render as "needs fixing". */
flagged?: Record<string, string>;
/** 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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<{
url: string;
title: string;
mimeType?: string | null;
} | null>(
null,
);
const resetRefs = useRef<Record<string, () => 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 (
<Stack gap="sm">
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{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 (
<Card
key={requirement.id}
withBorder
padding="md"
style={{
borderColor: flagRemark
? 'var(--mantine-color-orange-5)'
: uploaded
? 'var(--mantine-color-teal-4)'
: undefined,
borderStyle: uploaded ? 'solid' : 'dashed',
}}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={600} size="sm">
{localized(requirement.name)}
</Text>
{requirement.mode === 'CONDITIONAL' && (
<Badge size="xs" variant="light" color="grape">
{t('licensing.documents.conditional')}
</Badge>
)}
{requirement.mode === 'OPTIONAL' && (
<Badge size="xs" variant="light" color="gray">
{t('common.optional')}
</Badge>
)}
{uploaded && !flagRemark && (
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
{t('licensing.documents.uploaded')}
</Badge>
)}
{/* 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 && (
<Badge size="xs" variant="light" color="blue">
{t('licensing.documents.fromVault')}
</Badge>
)}
</Group>
{requirement.description && (
<Text size="xs" c="dimmed" mt={2}>
{localized(requirement.description)}
</Text>
)}
{files.map((file) => (
<Text key={file.id} size="xs" c="dimmed" truncate>
{file.originalName} · {(Number(file.sizeBytes) / 1024).toFixed(0)} KB
</Text>
))}
{flagRemark && (
<Text size="xs" c="orange.7" mt={4}>
{t('licensing.documents.officerRemark', { name: flagRemark })}
</Text>
)}
</div>
<Group gap="xs" wrap="nowrap">
{files
.filter((file) => file.url)
.map((file, index) => (
<Button
key={file.id}
size="xs"
variant="subtle"
onClick={() =>
setPreview({
url: file.url as string,
title: file.originalName,
mimeType: file.mimeType,
})
}
>
{files.length > 1
? `${t('licensing.documents.view')} ${index + 1}`
: t('licensing.documents.view')}
</Button>
))}
{!locked && (
<FileButton
resetRef={(r) => {
if (r) resetRefs.current[requirement.key] = r;
}}
onChange={(file) => handle(requirement.key, file)}
accept={requirement.allowedMimeTypes?.join(',')}
>
{(props) => (
<Button
{...props}
size="xs"
variant={uploaded ? 'light' : 'filled'}
leftSection={
busy === requirement.key ? (
<Loader size={12} type="oval" />
) : (
<IconFileUpload size={14} />
)
}
disabled={busy === requirement.key}
>
{uploaded
? t('licensing.documents.replace')
: t('licensing.documents.upload')}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
</Card>
);
})}
<FilePreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
mimeType={preview?.mimeType}
/>
</Stack>
);
}