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

191 lines
5.8 KiB
TypeScript

import { useRef, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
FileButton,
Group,
Loader,
Stack,
Text,
} from '@mantine/core';
import {
IconAlertTriangle,
IconCheck,
IconFileUpload,
IconTrash,
} from '@tabler/icons-react';
import {
conditionHolds,
localized,
uploadDocument,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
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;
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,
onUploaded,
readOnly,
}: Props) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const resetRefs = useRef<Record<string, () => void>>({});
const required = requirements.filter(
(r) =>
r.mode === 'ALWAYS' ||
(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(`File exceeds 5MB limit (${(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 flagRemark = flagged[requirement.key];
const locked = readOnly || (restrictToFlagged && !flagRemark);
return (
<Card
key={requirement.key}
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">
conditional
</Badge>
)}
{uploaded && !flagRemark && (
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
uploaded
</Badge>
)}
</Group>
{existing?.files?.[0] && (
<Text size="xs" c="dimmed" truncate>
{existing.files[0].originalName} ·{' '}
{(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
</Text>
)}
{flagRemark && (
<Text size="xs" c="orange.7" mt={4}>
Officer: {flagRemark}
</Text>
)}
</div>
<Group gap="xs" wrap="nowrap">
{existing?.files?.[0]?.url && (
<Button
size="xs"
variant="subtle"
component="a"
href={existing.files[0].url}
target="_blank"
>
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} />
) : (
<IconFileUpload size={14} />
)
}
disabled={busy === requirement.key}
>
{uploaded ? 'Replace' : 'Upload'}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
</Card>
);
})}
</Stack>
);
}