From 16ac650f5f235d5064e3e59db7d4964fc828ecc4 Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 31 Aug 2026 07:11:05 +0000 Subject: [PATCH 1/4] feat(applications): add discard functionality for draft applications and update UI --- .../certificates/pages/CertificatesPage.tsx | 91 +++++++++++++++---- .../pages/MyApplicationsPage/actions.tsx | 16 +++- .../pages/MyApplicationsPage/index.tsx | 40 +++++++- apps/portal/src/app/i18n/locales/am.ts | 4 + apps/portal/src/app/i18n/locales/en.ts | 5 + libs/api/src/lib/base-api/mock-base-query.ts | 9 ++ .../lib/features/licensing/licensing-api.ts | 6 ++ 7 files changed, 150 insertions(+), 21 deletions(-) diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index f7cf52743..469e2a6d2 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -28,19 +28,21 @@ import { IconEye, IconInfoCircle, IconShieldCheck, + IconTrash, } from '@tabler/icons-react'; import { authStorage, useCurrentProfile } from '@ema-platform/auth'; import { extractErrorMessage, useApiQuery, useBypassPaymentMutation, + useDiscardApplicationMutation, useGetPaymentCapabilitiesQuery, } from '@ema-platform/api'; import { useGetMySeaServiceRecordsQuery, useGetMyMedicalCertificatesQuery, } from '@ema-platform/api'; -import { PdfPreviewModal } from '@ema-platform/ui'; +import { ConfirmModal, PdfPreviewModal } from '@ema-platform/ui'; import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment'; // --------------------------------------------------------------------------- @@ -59,6 +61,8 @@ interface CertificatesOverview { id: string; applicationId: string; type: string; + /** Which license type this is a draft of — gates the Apply buttons. */ + licenseTypeKey: string | null; submitted: string; status: string; /** The fee owed at the current status, or null when nothing is due. */ @@ -153,6 +157,8 @@ export function CertificatesPage() { // never rendered. Same shortcut My Applications offers. const { data: capabilities } = useGetPaymentCapabilitiesQuery(); const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation(); + const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation(); + const [discardTarget, setDiscardTarget] = useState<{ id: string; applicationId: string } | null>(null); const { data, refetch } = useApiQuery({ url: '/certificates/my', @@ -175,6 +181,22 @@ export function CertificatesPage() { notifications.show({ color: 'red', title: 'Bypass failed', message: extractErrorMessage(err) }); } }; + const handleDiscard = async () => { + if (!discardTarget) return; + try { + await discardApplication(discardTarget.applicationId).unwrap(); + notifications.show({ + color: 'teal', + title: 'Draft discarded', + message: `${discardTarget.id} has been deleted.`, + }); + setDiscardTarget(null); + refetch(); + } catch (err) { + notifications.show({ color: 'red', title: 'Could not discard', message: extractErrorMessage(err) }); + } + }; + const certificates = data?.certificates ?? []; const applications = data?.applications ?? []; @@ -184,6 +206,12 @@ export function CertificatesPage() { // The three queries below only build the human-readable reason list for // the tooltip/banner — the gate itself is the one boolean. const { profile, eligibleForCoc: canApply } = useCurrentProfile(); + // One open draft per certificate type: the API resumes the existing draft + // rather than stacking a second, so the button says so instead of looking + // like it did nothing. + const draftTypeKeys = new Set( + applications.filter((a) => a.status === 'DRAFT').map((a) => a.licenseTypeKey), + ); const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery(); const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery(); @@ -251,23 +279,25 @@ export function CertificatesPage() { disabled, so the reason still shows on hover. */} - - + {([ + { key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const }, + { key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const }, + ]).map(({ key, label, variant }) => { + const hasDraft = draftTypeKeys.has(key); + return ( + + ); + })} @@ -372,8 +402,21 @@ export function CertificatesPage() { style={{ cursor: 'pointer' }} onClick={() => navigate(`/applications/${app.applicationId}`)} > - Details + {app.status === 'DRAFT' ? 'Continue' : 'Details'} + {/* Drafts only: once submitted the filing is a record, + and withdrawing it is the officer's call. */} + {app.status === 'DRAFT' && ( + + )} @@ -419,6 +462,16 @@ export function CertificatesPage() { )} + setDiscardTarget(null)} + onConfirm={handleDiscard} + loading={discarding} + title="Discard draft" + message={`Delete draft ${discardTarget?.id ?? ''}? Anything filled in so far is lost.`} + confirmLabel="Discard" + /> + setPreviewUrl(null)} diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx index 523a8c6ba..eda1deb92 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx @@ -1,5 +1,5 @@ import { Button, Group } from '@mantine/core'; -import { IconDownload } from '@tabler/icons-react'; +import { IconDownload, IconTrash } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { LicenseApplication } from '@ema-platform/api'; @@ -32,6 +32,7 @@ export function applicationActionsColumn( onPay: (app: LicenseApplication) => void; onRetakeExam: (app: LicenseApplication) => void; onOpen: (app: LicenseApplication) => void; + onDiscard: (app: LicenseApplication) => void; }, ): AdvancedColumn { return { @@ -126,6 +127,19 @@ export function applicationActionsColumn( : t('applications.actions.view')} )} + {/* Drafts only: after submission the filing is a record an officer + may already be reading, so it is withdrawn, not deleted. */} + {app.status === 'DRAFT' && ( + + )} ); }, diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx index 59f73dfaa..d2f173c7e 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx @@ -29,7 +29,7 @@ import { IconSearch, IconX, } from '@tabler/icons-react'; -import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui'; +import { AdvancedTable, AmharicDatePicker, ConfirmModal, EmptyState, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { LicenseCatalogue } from '../../components/LicenseCatalogue'; import { LicenseCard, useReissueLicense, useRenewLicense } from '../../components/LicenseCard'; @@ -42,6 +42,7 @@ import { applicantOrCompanyName, extractErrorMessage, useBypassPaymentMutation, + useDiscardApplicationMutation, useGetCertificateUrlMutation, useGetMyApplicationsQuery, useGetMyLicensesQuery, @@ -102,6 +103,8 @@ export function MyApplicationsPage() { const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation(); const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation(); const [getCertificateUrl] = useGetCertificateUrlMutation(); + const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation(); + const [discardTarget, setDiscardTarget] = useState<{ id: string; label: string } | null>(null); const { renewLicense, isRenewing } = useRenewLicense(); const { reissueLicense, isReissuing } = useReissueLicense(); const [isDownloadingCert, setIsDownloadingCert] = useState(false); @@ -135,6 +138,26 @@ export function MyApplicationsPage() { } } + /** Throws away an unfinished draft, after the applicant confirms. */ + async function handleDiscard() { + if (!discardTarget) return; + try { + await discardApplication(discardTarget.id).unwrap(); + notifications.show({ + color: 'teal', + title: t('applications.actions.discarded'), + message: discardTarget.label, + }); + setDiscardTarget(null); + } catch (err) { + notifications.show({ + color: 'red', + title: t('applications.actions.discardFailed'), + message: extractErrorMessage(err), + }); + } + } + /** * Re-opens the examination fee for a failed candidate. * @@ -290,6 +313,8 @@ export function MyApplicationsPage() { onRetakeExam: (app) => retakeExamFee(app.id), onOpen: (app) => navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`), + onDiscard: (app) => + setDiscardTarget({ id: app.id, label: app.applicationNumber }), }), ]; @@ -511,6 +536,19 @@ export function MyApplicationsPage() { {tab === 'apply' && } + + setDiscardTarget(null)} + onConfirm={handleDiscard} + loading={discarding} + title={t('applications.actions.discard')} + message={t('applications.actions.discardConfirm', { + number: discardTarget?.label ?? '', + })} + confirmLabel={t('applications.actions.discard')} + cancelLabel={t('common.cancel')} + /> ); } diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 8ae1589c8..00e868a86 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -229,6 +229,10 @@ export const am: Translations = { view: 'ይመልከቱ', bypass: 'ክፍያ ዝለል', renew: 'አድስ', + discard: 'አጥፋ', + discardConfirm: 'ረቂቅ {{number}} ይጥፋ? እስካሁን የተሞላው ሁሉ ይጠፋል።', + discarded: 'ረቂቁ ጠፍቷል', + discardFailed: 'ረቂቁን ማጥፋት አልተቻለም', }, notice: { paymentReceived: diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index abd50a6b4..3e3b0d7d6 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -229,6 +229,11 @@ export const en = { view: 'View', bypass: 'Bypass payment', renew: 'Renew', + discard: 'Discard', + discardConfirm: + 'Delete draft {{number}}? Anything filled in so far is lost.', + discarded: 'Draft discarded', + discardFailed: 'Could not discard draft', }, notice: { paymentReceived: diff --git a/libs/api/src/lib/base-api/mock-base-query.ts b/libs/api/src/lib/base-api/mock-base-query.ts index 8f2244afc..6bfe5f4aa 100644 --- a/libs/api/src/lib/base-api/mock-base-query.ts +++ b/libs/api/src/lib/base-api/mock-base-query.ts @@ -172,6 +172,15 @@ const handlers: MockHandler[] = [ return detail ? clone(detail) : undefined; }, }, + { + method: 'DELETE', + pattern: /^\/license-applications\/([\w-]+)$/, + respond: (_req, match) => { + delete mockApplications[match[1]]; + delete mockApplicationDetails[match[1]]; + return { deleted: true }; + }, + }, { method: 'PATCH', pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/, diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 20112598d..8756f8f46 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -343,6 +343,11 @@ export const licensingApi = baseApi invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]), }), + discardApplication: builder.mutation<{ deleted: boolean }, string>({ + query: (id) => ({ url: `/license-applications/${id}`, method: 'DELETE' }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]), + }), + getMyApplications: builder.query, void>({ query: () => ({ url: '/license-applications/mine' }), providesTags: () => [listTag('LicenseApplication')], @@ -1216,6 +1221,7 @@ export const { useUpdateLicenseValidityMutation, useGetLicenseTypeRequirementsQuery, useCreateApplicationMutation, + useDiscardApplicationMutation, useGetMyApplicationsQuery, useGetApplicationQuery, useInitiatePaymentMutation, From 317a1532b12267bd96a394fc68b08fb40fd6c0b8 Mon Sep 17 00:00:00 2001 From: estifanos Date: Mon, 31 Aug 2026 08:18:27 +0000 Subject: [PATCH 2/4] feat: implement auto-filling of license and registration document slots from personal vault and add UI labels for vault-sourced files --- .../DocumentRequirementEditorDrawer.tsx | 27 ++++++- .../components/DocumentsTab.tsx | 9 +++ apps/backoffice/src/app/i18n/locales/am.ts | 6 ++ apps/backoffice/src/app/i18n/locales/en.ts | 6 ++ .../licensing/components/DocumentSlots.tsx | 65 +++++++++++------ .../pages/LicenseApplicationPage.tsx | 44 +++++++++++- .../components/RegistrationDocuments.tsx | 72 ++++++++++++++----- .../components/RegistrationSummary.tsx | 8 ++- .../pages/SeafarerRegistrationPage.tsx | 62 ++++++++++++++-- apps/portal/src/app/i18n/locales/am.ts | 1 + apps/portal/src/app/i18n/locales/en.ts | 1 + .../lib/features/licensing/licensing-api.ts | 32 +++++++++ .../lib/features/licensing/licensing.types.ts | 6 ++ 13 files changed, 288 insertions(+), 51 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx index 9569913bc..387e1e5ba 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { + Alert, Button, Checkbox, Divider, @@ -12,6 +13,7 @@ import { Text, TextInput, } from '@mantine/core'; +import { IconInfoCircle } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { BilingualInput, ModalFooter } from '@ema-platform/ui'; import { @@ -225,6 +227,20 @@ export function DocumentRequirementEditorDrawer({ title={{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}} > + {personal && ( + } + title={t('certReq.doc.keyMatchTitle', 'Keys are what link this to a licence')} + > + {t( + 'certReq.doc.keyMatchBody', + 'An applicant who holds this document has it copied onto any licence application asking for a requirement with the same key. A key that matches nothing is still collected — it simply never fills a form by itself.', + )} + + )} + { const file = attachment.files?.[0]; + const fromVault = Boolean(attachment.copiedFromAttachmentId); const flagged = attachment.documentKey in flags; const verdict = verdictFor(attachment.documentKey); const pendingReject = attachment.documentKey in rejecting; @@ -199,6 +200,14 @@ export function DocumentsTab({ requirementByKey.get(attachment.documentKey)?.name, ) || attachment.documentKey} + {/* Filed from the applicant's own document vault rather + than uploaded against this application — worth knowing + when the same file turns up on several files. */} + {fromVault && ( + + {t("review.documents.fromVault", "From My Documents")} + + )} {verdict && ( (null); const [error, setError] = useState(null); - const [preview, setPreview] = useState<{ url: string; title: string } | null>( + const [preview, setPreview] = useState<{ + url: string; + title: string; + mimeType?: string | null; + } | null>( null, ); const resetRefs = useRef void>>({}); @@ -111,7 +115,8 @@ export function DocumentSlots({ {required.map((requirement) => { const existing = attachments.find((a) => a.documentKey === requirement.key); const uploaded = Boolean(existing?.files?.length); - const fileUrl = existing?.files?.[0]?.url; + const files = existing?.files ?? []; + const fromVault = Boolean(existing?.copiedFromAttachmentId); const flagRemark = flagged[requirement.key]; const locked = readOnly || @@ -154,18 +159,25 @@ export function DocumentSlots({ {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)} )} - {existing?.files?.[0] && ( - - {existing.files[0].originalName} ·{' '} - {(existing.files[0].sizeBytes / 1024).toFixed(0)} KB + {files.map((file) => ( + + {file.originalName} · {(Number(file.sizeBytes) / 1024).toFixed(0)} KB - )} + ))} {flagRemark && ( {t('licensing.documents.officerRemark', { name: flagRemark })} @@ -174,20 +186,26 @@ export function DocumentSlots({ - {fileUrl && ( - - )} + {files + .filter((file) => file.url) + .map((file, index) => ( + + ))} {!locked && ( { @@ -222,11 +240,12 @@ export function DocumentSlots({ ); })} - setPreview(null)} url={preview?.url ?? ''} title={preview?.title} + mimeType={preview?.mimeType} /> ); diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 2a75eeae5..16e46e7cb 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, @@ -44,6 +44,7 @@ import { useAddStaffMutation, useCreateApplicationMutation, useGetApplicationQuery, + useFillApplicationDocumentsFromVaultMutation, useGetAttachmentsQuery, useGetLicenseTypeRequirementsQuery, useGetMyVesselsQuery, @@ -430,6 +431,46 @@ export function LicenseApplicationPage() { if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1)); }, [active, steps.length]); + /** + * Fills the document slots from the applicant's own vault the first time + * they reach that step. + * + * Someone who already keeps their passport under My Documents should find it + * here rather than being asked to go and fetch the same file again. The + * server only fills empty slots, so this cannot overwrite anything they + * uploaded, and calling it twice costs one query. + */ + const [fillFromVault] = useFillApplicationDocumentsFromVaultMutation(); + const filledForApplication = useRef(null); + useEffect(() => { + const status = application?.status; + const editable = + status === "DRAFT" || + status === "RESUBMIT_REQUIRED" || + (status === "SUBMITTED" && !application?.assignedOfficerId); + if (steps[active]?.kind !== "documents" || !appId || !editable) return; + if (filledForApplication.current === appId) return; + filledForApplication.current = appId; + + fillFromVault(appId) + .unwrap() + .then((result) => { + // Nothing was copied: no need to disturb the queries. + if (result.filled.length) refetchAttachments(); + }) + // A vault that cannot be read is not a reason to block the form — the + // applicant can still upload by hand. + .catch(() => undefined); + }, [ + steps, + active, + appId, + application?.status, + application?.assignedOfficerId, + fillFromVault, + refetchAttachments, + ]); + if (loadingConfig || !config || !appId || !application) { return ; } @@ -578,6 +619,7 @@ export function LicenseApplicationPage() { const currentStep = steps[active]; + /** * Checks one step before moving past it. * diff --git a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx index e1fb715e3..cc3c19b21 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx @@ -2,38 +2,69 @@ 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, + conditionHolds, uploadDocument, + useLocalized, type Attachment, + type DocumentRequirement, } 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)); +/** A configured requirement, with whether this applicant must supply it. */ +export type RegistrationSlot = DocumentRequirement & { isRequired: boolean }; + +/** + * The document slots a registration asks for. + * + * Configuration, not a constant: these are `document_requirements` rows + * against the SEAFARER_REGISTRATION licence type, the same ones the backoffice + * edits and the server validates against. A conditional requirement — the + * passport copy, wanted once a passport number is declared — is evaluated + * against the answers under `identity`, exactly as the server does it, so the + * wizard and the submit check can never disagree about what is being asked + * for. + */ +export function documentSlots( + requirements: DocumentRequirement[], + answers: Record, +): RegistrationSlot[] { + const context = { identity: answers, personal: answers } as Record< + string, + Record + >; + return requirements + .filter( + (requirement) => + requirement.mode !== 'CONDITIONAL' || + conditionHolds(requirement.conditionExpression, context), + ) + .map((requirement) => ({ + ...requirement, + isRequired: + requirement.mode === 'ALWAYS' || + (requirement.mode === 'CONDITIONAL' && + conditionHolds(requirement.conditionExpression, context)), + })); } export function RegistrationDocuments({ registrationId, - passportDeclared, - nationality, + requirements, + answers, attachments, readOnly, onUploaded, }: { registrationId: string; - passportDeclared: boolean; - nationality?: string | null; + requirements: DocumentRequirement[]; + /** The registration's answers, for evaluating conditional requirements. */ + answers: Record; attachments: Attachment[]; readOnly?: boolean; onUploaded: () => void; }) { + const localized = useLocalized(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const resetRefs = useRef void>>({}); @@ -66,9 +97,10 @@ export function RegistrationDocuments({ {error} )} - {documentSlots(passportDeclared, nationality).map((slot) => { + {documentSlots(requirements, answers).map((slot) => { const existing = attachments.find((a) => a.documentKey === slot.key); const uploaded = Boolean(existing?.files?.length); + const fromVault = Boolean(existing?.copiedFromAttachmentId); return ( - {slot.name} + {localized(slot.name)} {!slot.isRequired && ( @@ -95,10 +127,16 @@ export function RegistrationDocuments({ uploaded )} + {/* Copied from My Documents rather than uploaded here. */} + {fromVault && ( + + from My Documents + + )} {slot.description && ( - {slot.description} + {localized(slot.description)} )} {existing?.files?.[0] && ( @@ -119,7 +157,7 @@ export function RegistrationDocuments({ if (r) resetRefs.current[slot.key] = r; }} onChange={(file) => handle(slot.key, file)} - accept={slot.accept} + accept={slot.allowedMimeTypes?.join(',')} > {(props) => (