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/features/certification/pages/CertificationPage/index.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx index 323703738..2574aeb2e 100644 --- a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; -import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core'; +import { Stack, Button, Modal, Text, TextInput, Textarea, Select, Card, Switch } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import {IconPlus} from '@tabler/icons-react'; -import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui'; -import { useGetRanksQuery, useLocalized } from '@ema-platform/api'; +import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useServerTable } from '@ema-platform/ui'; +import { extractErrorMessage, useGetRanksQuery, useLocalized } from '@ema-platform/api'; import { useGetCertificationsQuery, useCreateCertificationMutation, @@ -15,6 +15,15 @@ import { type Certification } from '../../types/certification'; import { certificationColumns } from './columns'; import { certificationActionsColumn } from './actions'; +interface CertificationFormValues { + nameEn: string; + nameAm: string; + descEn: string; + descAm: string; + rankKey: string | null; + isActive: boolean; +} + function CertificationForm({ editing, rankOptions, @@ -25,7 +34,7 @@ function CertificationForm({ editing: Certification | null; rankOptions: { value: string; label: string }[]; isSubmitting: boolean; - onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void; + onSubmit: (values: CertificationFormValues, isEdit: boolean) => void; onCancel: () => void; }) { const { t } = useTranslation(); @@ -34,14 +43,15 @@ function CertificationForm({ const [descEn, setDescEn] = useState(editing?.description?.en ?? ''); const [descAm, setDescAm] = useState(editing?.description?.am ?? ''); const [rankKey, setRankKey] = useState(editing?.rankKey ?? null); + const [isActive, setIsActive] = useState(editing?.isActive ?? true); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!nameEn || !nameAm) { - notify.error('Name fields are required'); + if (!nameEn.trim() || !nameAm.trim()) { + notify.error(t('certification.validation.nameRequired', 'Both English and Amharic names are required')); return; } - onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing); + onSubmit({ nameEn: nameEn.trim(), nameAm: nameAm.trim(), descEn, descAm, rankKey, isActive }, !!editing); }; return ( @@ -63,6 +73,18 @@ function CertificationForm({ clearable searchable /> + {editing && ( + setIsActive(e.currentTarget.checked)} + size="sm" + /> + )} @@ -76,8 +98,10 @@ function CertificationForm({ export function CertificationPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; - const { handleError } = useErrorHandler(); const localized = useLocalized(); + // Server refusals (`rank_not_found`, `certification_in_use`) arrive as + // codes; this maps them to the sentences the administrator can act on. + const showError = (e: unknown) => notify.error(extractErrorMessage(e, t('certification.error'))); const { data, isFetching, isError, refetch } = useGetCertificationsQuery(); const { data: rankRes } = useGetRanksQuery(); const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) })); @@ -98,14 +122,14 @@ export function CertificationPage() { setShowForm(false); }; - const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => { + const handleSubmit = async (values: CertificationFormValues, isEdit: boolean) => { const name = { en: values.nameEn, am: values.nameAm }; const description = { en: values.descEn, am: values.descAm }; try { if (isEdit && editing) { // null clears a previously-set rank; undefined would leave it // untouched server-side, so the two are not interchangeable here. - await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap(); + await updateCert({ id: editing.id, name, description, rankKey: values.rankKey, isActive: values.isActive }).unwrap(); notify.success(t('certification.updated')); } else { await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap(); @@ -113,7 +137,7 @@ export function CertificationPage() { } resetForm(); } catch (e) { - handleError(e); + showError(e); } }; @@ -125,7 +149,7 @@ export function CertificationPage() { closeDelete(); setDeleteTarget(null); } catch (e) { - handleError(e); + showError(e); } }; @@ -184,7 +208,7 @@ export function CertificationPage() { - {t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })} + {t('certification.deleteConfirmText', { name: deleteTarget ? localized(deleteTarget.name) : '' })} diff --git a/apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx b/apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx new file mode 100644 index 000000000..75003dbec --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx @@ -0,0 +1,535 @@ +import { useMemo, useState } from 'react'; +import { + Alert, + Anchor, + Badge, + Button, + Card, + Checkbox, + Group, + Modal, + NumberInput, + Select, + SimpleGrid, + Stack, + Switch, + Text, + TextInput, + Textarea, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconInfoCircle, IconPlus } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { + AdvancedTable, + ErrorState, + ModalFooter, + notify, + PageLoader, + useServerTable, + type AdvancedColumn, +} from '@ema-platform/ui'; +import { + extractErrorMessage, + useCreateLicenseTypeMutation, + useGetLicenseCategoriesQuery, + useGetLicenseTypesQuery, + useLocalized, + useUpdateLicenseStatusMutation, + useUpdateLicenseTypeMutation, + type LicenseTypeCreate, + type FamilyKind, + type LicenseCategory, + type LicenseType, + type ServiceKind, + type WorkflowProfile, +} from '@ema-platform/api'; +import { LICENSE_PERMISSIONS, RequirePermission, usePermissions } from '@ema-platform/auth'; + +/** Upper snake case, as the server normalises and then requires. */ +const KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/; + +interface FormValues { + key: string; + nameEn: string; + nameAm: string; + descEn: string; + descAm: string; + category: LicenseCategory | ''; + familyKind: FamilyKind; + serviceKind: ServiceKind; + workflowProfile: WorkflowProfile; + certificatePrefix: string; + feeNewApplication: number | ''; + feeCurrency: string; + validityMonths: number; + issuesCertificate: boolean; + renewalEnabled: boolean; + inspectionRequired: boolean; +} + +const INITIAL: FormValues = { + key: '', + nameEn: '', + nameAm: '', + descEn: '', + descAm: '', + category: '', + familyKind: 'LOGISTICS_LICENSE', + serviceKind: 'LICENSE', + workflowProfile: 'STANDARD', + certificatePrefix: '', + feeNewApplication: '', + feeCurrency: 'ETB', + validityMonths: 12, + issuesCertificate: true, + renewalEnabled: true, + inspectionRequired: true, +}; + +/** + * The catalogue of licence types, with the one thing no other screen offers: + * creating a new one, and switching one on or off. + * + * Deliberately thin. A type is created with only what it needs to exist and + * be classified; its form, document slots, fees and behaviour rules each + * have a dedicated screen, and this one links there rather than duplicating + * them. Deactivating hides the type from the portal catalogue without + * touching applications already in flight, which hold the type by id. + */ +export function LicenseTypesTab() { + const { t } = useTranslation(); + const localized = useLocalized(); + const { can } = usePermissions(); + + const { data, isLoading, isFetching, isError, error, refetch } = useGetLicenseTypesQuery(); + const { data: categoriesRes } = useGetLicenseCategoriesQuery(); + const [createType, { isLoading: isCreating }] = useCreateLicenseTypeMutation(); + const [updateType, { isLoading: isUpdating }] = useUpdateLicenseTypeMutation(); + const [updateStatus, { isLoading: isToggling }] = useUpdateLicenseStatusMutation(); + + const [showForm, setShowForm] = useState(false); + const [editing, setEditing] = useState(null); + const [pendingToggle, setPendingToggle] = useState(null); + const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable(); + + const types = useMemo( + () => [...(data?.items ?? [])].sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key)), + [data], + ); + + const categoryOptions = useMemo( + () => + [...(categoriesRes?.items ?? [])] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((c) => ({ value: c.key, label: localized(c.name) })), + [categoriesRes, localized], + ); + const categoryLabel = (key: string) => categoryOptions.find((c) => c.value === key)?.label ?? key; + + const familyOptions: { value: FamilyKind; label: string }[] = [ + { value: 'LOGISTICS_LICENSE', label: t('configuration.licenseTypes.family.LOGISTICS_LICENSE', 'Logistics licence') }, + { value: 'CERTIFICATE', label: t('configuration.licenseTypes.family.CERTIFICATE', 'Seafarer certificate') }, + { value: 'DOCUMENT', label: t('configuration.licenseTypes.family.DOCUMENT', 'Identity / statutory document') }, + ]; + const serviceOptions: { value: ServiceKind; label: string }[] = [ + { value: 'LICENSE', label: t('configuration.licenseTypes.service.LICENSE', 'Licence') }, + { value: 'REGISTRATION', label: t('configuration.licenseTypes.service.REGISTRATION', 'Registration') }, + ]; + const workflowOptions: { value: WorkflowProfile; label: string }[] = [ + { value: 'STANDARD', label: t('configuration.licenseTypes.workflow.STANDARD', 'Standard (review → evaluation → inspection → approval)') }, + { value: 'REGISTRATION', label: t('configuration.licenseTypes.workflow.REGISTRATION', 'Registration (review → approval)') }, + ]; + + const form = useForm({ + initialValues: INITIAL, + transformValues: (v) => ({ ...v, key: v.key.trim().toUpperCase(), certificatePrefix: v.certificatePrefix.trim() }), + validate: { + key: (v) => { + const key = v.trim().toUpperCase(); + if (!key) return t('configuration.licenseTypes.validation.keyRequired', 'Key is required'); + if (key.length > 64) return t('configuration.licenseTypes.validation.keyTooLong', 'Key must be at most 64 characters'); + if (!KEY_PATTERN.test(key)) { + return t('configuration.licenseTypes.validation.keyFormat', 'Use letters, digits and underscores, e.g. PORT_AGENT'); + } + if (types.some((lt) => lt.key === key && lt.id !== editing?.id)) { + return t('configuration.licenseTypes.validation.keyTaken', 'A licence type with this key already exists'); + } + return null; + }, + nameEn: (v) => (v.trim() ? null : t('configuration.validation.nameEnRequired')), + nameAm: (v) => (v.trim() ? null : t('configuration.validation.nameAmRequired')), + certificatePrefix: (v) => { + const prefix = v.trim(); + if (!prefix) return t('configuration.licenseTypes.validation.prefixRequired', 'Certificate prefix is required'); + if (prefix.length > 12) return t('configuration.licenseTypes.validation.prefixTooLong', 'Prefix must be at most 12 characters'); + return null; + }, + feeCurrency: (v) => (v.trim().length > 8 ? t('configuration.licenseTypes.validation.currencyTooLong', 'Use a short currency code') : null), + validityMonths: (v) => + v >= 6 && v <= 240 ? null : t('configuration.licenseTypes.validation.validityRange', 'Validity must be between 6 and 240 months'), + }, + }); + + const closeForm = () => { + form.reset(); + setEditing(null); + setShowForm(false); + }; + + const openCreate = () => { + form.reset(); + setEditing(null); + setShowForm(true); + }; + + const openEdit = (licenseType: LicenseType) => { + setEditing(licenseType); + form.setValues({ + key: licenseType.key, + nameEn: licenseType.name?.en ?? '', + nameAm: licenseType.name?.am ?? '', + descEn: licenseType.description?.en ?? '', + descAm: licenseType.description?.am ?? '', + category: licenseType.category ?? '', + familyKind: licenseType.familyKind ?? 'LOGISTICS_LICENSE', + serviceKind: licenseType.serviceKind ?? 'LICENSE', + workflowProfile: licenseType.workflowProfile ?? 'STANDARD', + certificatePrefix: licenseType.certificatePrefix, + feeNewApplication: + licenseType.feeNewApplication === null || licenseType.feeNewApplication === undefined + ? '' + : Number(licenseType.feeNewApplication), + feeCurrency: licenseType.feeCurrency ?? 'ETB', + validityMonths: licenseType.validityMonths ?? 12, + issuesCertificate: licenseType.issuesCertificate ?? true, + renewalEnabled: licenseType.renewalEnabled ?? true, + inspectionRequired: licenseType.inspectionRequired ?? true, + }); + setShowForm(true); + }; + + const submit = form.onSubmit(async (values) => { + // Everything both paths write. `key` and `sortOrder` are deliberately not + // here: applications, licences and the portal's own routes address a type + // by its key, so renaming one in place would strand everything already + // pointing at the old name — the server refuses it too, once any + // application references the type. + const shared: Omit = { + name: { en: values.nameEn.trim(), am: values.nameAm.trim() }, + certificatePrefix: values.certificatePrefix, + familyKind: values.familyKind, + serviceKind: values.serviceKind, + workflowProfile: values.workflowProfile, + feeCurrency: values.feeCurrency.trim() || 'ETB', + validityMonths: values.validityMonths, + issuesCertificate: values.issuesCertificate, + renewalEnabled: values.renewalEnabled, + inspectionRequired: values.inspectionRequired, + }; + if (values.descEn.trim() || values.descAm.trim()) { + shared.description = { en: values.descEn.trim(), am: values.descAm.trim() }; + } + if (values.category) shared.category = values.category; + if (values.feeNewApplication !== '') shared.feeNewApplication = values.feeNewApplication; + + try { + if (editing) { + await updateType({ id: editing.id, ...shared }).unwrap(); + notify.success(t('configuration.updated')); + } else { + await createType({ + ...shared, + key: values.key, + // The last row by default; the seeded order is EMA's and stays put. + sortOrder: types.length, + }).unwrap(); + notify.success( + t('configuration.licenseTypes.created', 'Licence type created. Configure its form, documents and fees next.'), + ); + } + closeForm(); + } catch (e) { + notify.error(extractErrorMessage(e, t('configuration.error'))); + } + }); + + const confirmToggle = async () => { + if (!pendingToggle) return; + const next = !pendingToggle.isActive; + try { + await updateStatus({ id: pendingToggle.id, isActive: next }).unwrap(); + notify.success( + next + ? t('configuration.licenseTypes.activated', 'Licence type is now accepting applications') + : t('configuration.licenseTypes.deactivated', 'Licence type is closed to new applications'), + ); + setPendingToggle(null); + } catch (e) { + notify.error(extractErrorMessage(e, t('configuration.error'))); + } + }; + + if (isError) { + return ( + refetch()} + /> + ); + } + if (isLoading) return ; + + const canToggle = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]); + + const columns: AdvancedColumn[] = [ + { header: t('configuration.name'), cell: ({ row }) => localized(row.original.name) }, + { header: t('configuration.key', 'Key'), cell: ({ row }) => {row.original.key} }, + { header: t('configuration.licenseTypes.columns.category', 'Category'), cell: ({ row }) => categoryLabel(row.original.category) }, + { + header: t('configuration.licenseTypes.columns.family', 'Family'), + cell: ({ row }) => familyOptions.find((f) => f.value === row.original.familyKind)?.label ?? row.original.familyKind, + }, + { header: t('configuration.licenseTypes.columns.prefix', 'Prefix'), cell: ({ row }) => row.original.certificatePrefix }, + { + header: t('configuration.licenseTypes.columns.status', 'Status'), + size: 110, + cell: ({ row }) => ( + + {row.original.isActive ? t('configuration.licenseTypes.active', 'Active') : t('configuration.licenseTypes.inactive', 'Inactive')} + + ), + }, + { + header: '', + label: t('configuration.licenseTypes.columns.actions', 'Actions'), + size: 220, + cell: ({ row }) => ( + + + {/* + Bound to the stored flag rather than to local state: the switch + only opens the confirmation, and it moves once the server has + accepted. Flipping first would claim the type was closed while + the request was still in the air. + */} + setPendingToggle(row.original)} + label={ + row.original.isActive + ? t('configuration.licenseTypes.deactivate', 'Deactivate') + : t('configuration.licenseTypes.activate', 'Activate') + } + aria-label={t( + 'configuration.licenseTypes.toggleAria', + 'Toggle whether this licence type accepts applications', + )} + /> + + ), + }, + ]; + + const page = paginate(types); + + return ( + + }> + + {t( + 'configuration.licenseTypes.notice', + 'A new type starts empty. After creating it, set up its form and document slots under Certificate Requirements, and its fees under Payment Configuration.', + )}{' '} + + {t('configuration.licenseTypes.goToRequirements', 'Certificate requirements')} + + {' · '} + + {t('configuration.licenseTypes.goToFees', 'Payment configuration')} + + + + + + + + + + + + + + + +
+ + + form.setFieldValue('key', e.currentTarget.value.toUpperCase())} + disabled={Boolean(editing)} + /> + + + + + + +