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)} + /> + + + + + + +