mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 17:38:14 +00:00
191 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|