Files
emaui/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx

145 lines
4.9 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 {
SEAFARER_REGISTRATION_DOCUMENTS,
isEthiopianNationality,
uploadDocument,
type Attachment,
} from '@ema-platform/api';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
/** The document slots a registration asks for. */
export function documentSlots(passportDeclared: boolean, nationality?: string | null) {
const ethiopian = isEthiopianNationality(nationality);
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
...d,
isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
})).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
}
export function RegistrationDocuments({
registrationId,
passportDeclared,
nationality,
attachments,
readOnly,
onUploaded,
}: {
registrationId: string;
passportDeclared: boolean;
nationality?: string | null;
attachments: Attachment[];
readOnly?: boolean;
onUploaded: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const resetRefs = useRef<Record<string, () => void>>({});
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: 'SEAFARER_REGISTRATION',
ownerId: registrationId,
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>
)}
{documentSlots(passportDeclared, nationality).map((slot) => {
const existing = attachments.find((a) => a.documentKey === slot.key);
const uploaded = Boolean(existing?.files?.length);
return (
<Card
key={slot.key}
withBorder
padding="md"
style={{
borderColor: 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">
{slot.name}
</Text>
{!slot.isRequired && (
<Badge size="xs" variant="light" color="gray">
optional
</Badge>
)}
{uploaded && (
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
uploaded
</Badge>
)}
</Group>
{slot.description && (
<Text size="xs" c="dimmed" mt={2}>
{slot.description}
</Text>
)}
{existing?.files?.[0] && (
<Text size="xs" c="dimmed" truncate mt={2}>
{existing.files[0].originalName} · {(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
</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>
)}
{!readOnly && (
<FileButton
resetRef={(r) => {
if (r) resetRefs.current[slot.key] = r;
}}
onChange={(file) => handle(slot.key, file)}
accept={slot.accept}
>
{(props) => (
<Button
{...props}
size="xs"
variant={uploaded ? 'light' : 'filled'}
leftSection={busy === slot.key ? <Loader size={12} /> : <IconFileUpload size={14} />}
disabled={busy === slot.key}
>
{uploaded ? 'Replace' : 'Upload'}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
</Card>
);
})}
</Stack>
);
}