From 6d3a4c14d567ba304a9207f1b1dc26fa3fad15b7 Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:28:00 +0300 Subject: [PATCH] feat: add ImportPersonalDocumentsModal and related components to support importing document requirements from personal vaults --- .../DocumentRequirementEditorDrawer.tsx | 135 +++++- .../components/DocumentRequirementsTab.tsx | 67 ++- .../ImportPersonalDocumentsModal.tsx | 431 ++++++++++++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 15 + apps/backoffice/src/app/i18n/locales/en.ts | 15 + 5 files changed, 628 insertions(+), 35 deletions(-) create mode 100644 apps/backoffice/src/app/features/certificate-requirements/components/ImportPersonalDocumentsModal.tsx 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 fcf36a312..32d1f5425 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Alert, Button, Checkbox, Divider, Drawer, + Group, MultiSelect, NumberInput, SegmentedControl, @@ -13,10 +14,11 @@ import { Text, TextInput, } from '@mantine/core'; -import { IconInfoCircle } from '@tabler/icons-react'; +import { IconCheck, IconDownload, IconInfoCircle } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { BilingualInput, ModalFooter } from '@ema-platform/ui'; import { + useGetPersonalDocumentsQuery, useLocalized, type ApplicationKind, type DocumentRequirement, @@ -74,7 +76,7 @@ const MIME_OPTIONS = [ /** What a new slot accepts until someone widens it. */ const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png']; -type DraftRequirement = Omit; +export type DraftRequirement = Omit; /** * Which licences a personal document is asked for. @@ -117,6 +119,7 @@ export function DocumentRequirementEditorDrawer({ opened, onClose, requirement, + initialDraft, defaultApplicationKind, onSave, palette, @@ -130,6 +133,8 @@ export function DocumentRequirementEditorDrawer({ onClose: () => void; /** Null = adding a new requirement. */ requirement: DocumentRequirement | null; + /** Optional initial prefill when adding a new requirement (e.g. from personal docs). */ + initialDraft?: Partial | null; defaultApplicationKind: ApplicationKind; onSave: (draft: DraftRequirement, scope: PersonalScope) => void; palette: FormSchemaPalette | undefined; @@ -142,7 +147,7 @@ export function DocumentRequirementEditorDrawer({ /** The licence types this document is already scoped to. */ scope?: PersonalScope; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const localized = useLocalized(); const [draft, setDraft] = useState( emptyDraft(defaultApplicationKind, personal), @@ -150,36 +155,84 @@ export function DocumentRequirementEditorDrawer({ const [scopeIds, setScopeIds] = useState(scope); const [appliesToAll, setAppliesToAll] = useState(scope.length === 0); const [keyError, setKeyError] = useState(null); + const [selectedPersonalKey, setSelectedPersonalKey] = useState(null); const isNew = !requirement; + const { data: personalDocsData } = useGetPersonalDocumentsQuery( + { take: 100, locale: i18n.language === 'am' ? 'am' : 'en' }, + { skip: !opened || !isNew || personal }, + ); + + const personalDocOptions = useMemo( + () => + (personalDocsData?.items ?? []).map((item) => ({ + value: item.key, + label: `${localized(item.rows[0]?.name) || item.key} (${item.key})`, + })), + [personalDocsData, localized], + ); + useEffect(() => { if (opened) { - setDraft( - requirement - ? { - key: requirement.key, - name: { ...requirement.name }, - description: requirement.description ? { ...requirement.description } : undefined, - applicationKind: requirement.applicationKind, - mode: requirement.mode, - conditionExpression: requirement.conditionExpression, - allowedMimeTypes: requirement.allowedMimeTypes, - maxSizeMb: requirement.maxSizeMb, - requiresValidityDates: requirement.requiresValidityDates, - allowMultiple: requirement.allowMultiple, - isPersonal: requirement.isPersonal ?? personal, - maxFiles: requirement.maxFiles ?? null, - sortOrder: requirement.sortOrder, - } - : emptyDraft(defaultApplicationKind, personal), - ); + if (requirement) { + setDraft({ + key: requirement.key, + name: { ...requirement.name }, + description: requirement.description ? { ...requirement.description } : undefined, + applicationKind: requirement.applicationKind, + mode: requirement.mode, + conditionExpression: requirement.conditionExpression, + allowedMimeTypes: requirement.allowedMimeTypes, + maxSizeMb: requirement.maxSizeMb, + requiresValidityDates: requirement.requiresValidityDates, + allowMultiple: requirement.allowMultiple, + isPersonal: requirement.isPersonal ?? personal, + maxFiles: requirement.maxFiles ?? null, + sortOrder: requirement.sortOrder, + }); + setSelectedPersonalKey(null); + } else if (initialDraft) { + setDraft({ + ...emptyDraft(defaultApplicationKind, personal), + ...initialDraft, + }); + setSelectedPersonalKey(initialDraft.key ?? null); + } else { + setDraft(emptyDraft(defaultApplicationKind, personal)); + setSelectedPersonalKey(null); + } setScopeIds(scope); setAppliesToAll(scope.length === 0); setKeyError(null); } // `scope` is a fresh array each render; the opened flag is what gates this. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [opened, requirement, defaultApplicationKind, personal]); + }, [opened, requirement, initialDraft, defaultApplicationKind, personal]); + + function handleSelectPersonalDoc(key: string | null) { + setSelectedPersonalKey(key); + if (!key) return; + const found = personalDocsData?.items?.find((item) => item.key === key); + if (found && found.rows[0]) { + const row = found.rows[0]; + setDraft((d) => ({ + ...d, + key: found.key, + name: { en: row.name?.en ?? '', am: row.name?.am ?? '' }, + description: row.description + ? { en: row.description?.en ?? '', am: row.description?.am ?? '' } + : undefined, + allowedMimeTypes: row.allowedMimeTypes?.length + ? [...row.allowedMimeTypes] + : d.allowedMimeTypes, + maxSizeMb: row.maxSizeMb ?? d.maxSizeMb, + maxFiles: row.maxFiles ?? null, + requiresValidityDates: row.requiresValidityDates ?? false, + allowMultiple: (row.maxFiles ?? 1) !== 1, + })); + setKeyError(null); + } + } function save() { if (!draft.key.trim()) { @@ -241,6 +294,40 @@ export function DocumentRequirementEditorDrawer({ )} + {!personal && isNew && ( + + v && setImportMode(v as DocumentRequirement['mode'])} + allowDeselect={false} + /> + + + {t('certReq.personal.fileCount', '{{count}} document(s) available', { + count: selectableKeys.length, + })} + + + + {isLoading ? ( + + + + {t('certReq.doc.loading', 'Loading personal documents…')} + + + ) : isError ? ( + }> + {t('certReq.doc.loadFailed', 'Could not load personal documents')} + + + ) : filteredGroups.length === 0 ? ( + + + {search + ? t('certReq.personal.noMatch', 'No personal document matches those filters.') + : t('certReq.doc.noPersonalDocs', 'No personal documents configured in Configuration yet.')} + + + ) : ( + + + + + + + + {t('certReq.personal.columns.document', 'Document')} + {t('certReq.doc.allowedTypes', 'Format & Limits')} + {t('certReq.doc.scope', 'Scope')} + {t('certReq.personal.columns.actions', 'Actions')} + + + + {filteredGroups.map((item) => { + const isSelected = selectedKeys.includes(item.key); + return ( + + + toggleSelectOne(item.key)} + /> + + + +
+ + + {localized(item.mainRow.name) || item.key} + + {item.isAlreadyAdded && ( + + {t('certReq.doc.alreadyAdded', 'Already added')} + + )} + + + {item.key} + +
+
+
+ + + {(item.mainRow.allowedMimeTypes ?? []).slice(0, 3).map(shortMime).join(', ')} + {(item.mainRow.allowedMimeTypes ?? []).length > 3 && '...'} + + + {item.mainRow.maxSizeMb} MB ·{' '} + {item.mainRow.maxFiles === null + ? t('certReq.doc.maxFilesUnlimited', 'No limit') + : `${item.mainRow.maxFiles} file(s)`} + {item.mainRow.requiresValidityDates && ' · Validity'} + + + + {item.isGlobal ? ( + + {t('certReq.doc.scopeAll', 'All licences')} + + ) : ( + + {t('certReq.doc.scopeSelected', 'Selected')} + + )} + + + + handleCustomizeRow(item)} + > + + + + +
+ ); + })} +
+
+
+ )} + + + + + +
+ + ); +} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 04a39b99d..28090dba0 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1617,6 +1617,21 @@ export const am: Translations = { maxFilesUnlimited: "ገደብ የለም", sortOrder: "የቅደም ተከተል ቁጥር", when: "መቼ", + importPersonal: "ከግል ሰነዶች አስመጣ", + importPersonalDesc: + "ለዚህ የፈቃድ አይነት እንደ መስፈርት ለማከል በውቅር ገጽ ላይ ከተዋቀሩት የግል ሰነዶች ይምረጡ።", + importFromPersonal: "ከግል ሰነድ አስመጣ", + selectPersonalDoc: "ዝርዝሩን ለመቅዳት የግል ሰነድ ይምረጡ…", + importSelected: "የተመረጡትን አስመጣ ({{count}})", + importAction: "አስመጣ", + customizeAndAdd: "አስተካክለህ ጨምር", + alreadyAdded: "ቀደም ሲል ተጨምሯል", + noPersonalDocs: "በውቅር ውስጥ የተዘጋጀ የግል ሰነድ እስካሁን የለም።", + importedFromPersonal: "ከግል ሰነድ የመጣ: {{name}}", + importedHelper: + "ከግል ሰነዶች የመጣ። ቁልፉ ከአመልካቹ ሰነዶች ጋር ስለሚዛመድ ፋይሎች በቀጥታ ይገናኛሉ።", + importMode: "ለሚመጡ ሰነዶች የመስፈርት ሁነታ", + importSuccess: "{{count}} የግል ሰነድ(ዶች) ገብተዋል", }, personal: { title: "የግል ሰነዶች", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index dcf9f9a2b..82fb3b144 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -1624,6 +1624,21 @@ export const en = { maxFilesUnlimited: 'No limit', sortOrder: 'Sort order', when: 'when', + importPersonal: 'Import from personal documents', + importPersonalDesc: + 'Select documents configured in Personal Documents to add as requirements for this licence type.', + importFromPersonal: 'Import from personal document', + selectPersonalDoc: 'Select a personal document to copy details…', + importSelected: 'Import selected ({{count}})', + importAction: 'Import', + customizeAndAdd: 'Customize & add', + alreadyAdded: 'Already added', + noPersonalDocs: 'No personal documents configured in Configuration yet.', + importedFromPersonal: 'Imported from personal document: {{name}}', + importedHelper: + 'Imported from personal documents. The key matches the vault so uploaded files will link automatically.', + importMode: 'Requirement mode for imported documents', + importSuccess: 'Imported {{count}} personal document(s)', }, personal: { title: 'Personal documents',