From 5eeb9602b0b0d70bd809ce13ea10d661a9131faf Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 2 Sep 2026 11:10:27 +0000 Subject: [PATCH] feat: implement license type management with creation and status toggling functionality --- .../pages/CertificationPage/index.tsx | 50 +- .../components/LicenseTypesTab.tsx | 457 ++++++++++++++++++ .../pages/ConfigurationPage/index.tsx | 9 + apps/backoffice/src/app/i18n/locales/am.ts | 73 +++ apps/backoffice/src/app/i18n/locales/en.ts | 81 +++- libs/api/src/lib/base-api/mock-base-query.ts | 45 ++ .../lib/features/licensing/licensing-api.ts | 57 +++ .../features/licensing/licensing.helpers.ts | 12 + 8 files changed, 767 insertions(+), 17 deletions(-) create mode 100644 apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx 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..acc686d96 --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx @@ -0,0 +1,457 @@ +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, + type CreateLicenseTypeInput, + 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 [updateStatus, { isLoading: isToggling }] = useUpdateLicenseStatusMutation(); + + const [showForm, setShowForm] = useState(false); + 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('licenseTypeConfig.family.LOGISTICS_LICENSE', 'Logistics licence') }, + { value: 'CERTIFICATE', label: t('licenseTypeConfig.family.CERTIFICATE', 'Seafarer certificate') }, + { value: 'DOCUMENT', label: t('licenseTypeConfig.family.DOCUMENT', 'Identity / statutory document') }, + ]; + const serviceOptions: { value: ServiceKind; label: string }[] = [ + { value: 'LICENSE', label: t('licenseTypeConfig.service.LICENSE', 'Licence') }, + { value: 'REGISTRATION', label: t('licenseTypeConfig.service.REGISTRATION', 'Registration') }, + ]; + const workflowOptions: { value: WorkflowProfile; label: string }[] = [ + { value: 'STANDARD', label: t('licenseTypeConfig.workflow.STANDARD', 'Standard (review → evaluation → inspection → approval)') }, + { value: 'REGISTRATION', label: t('licenseTypeConfig.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('licenseTypeConfig.validation.keyRequired', 'Key is required'); + if (key.length > 64) return t('licenseTypeConfig.validation.keyTooLong', 'Key must be at most 64 characters'); + if (!KEY_PATTERN.test(key)) { + return t('licenseTypeConfig.validation.keyFormat', 'Use letters, digits and underscores, e.g. PORT_AGENT'); + } + if (types.some((lt) => lt.key === key)) { + return t('licenseTypeConfig.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('licenseTypeConfig.validation.prefixRequired', 'Certificate prefix is required'); + if (prefix.length > 12) return t('licenseTypeConfig.validation.prefixTooLong', 'Prefix must be at most 12 characters'); + return null; + }, + feeCurrency: (v) => (v.trim().length > 8 ? t('licenseTypeConfig.validation.currencyTooLong', 'Use a short currency code') : null), + validityMonths: (v) => + v >= 6 && v <= 240 ? null : t('licenseTypeConfig.validation.validityRange', 'Validity must be between 6 and 240 months'), + }, + }); + + const closeForm = () => { + form.reset(); + setShowForm(false); + }; + + const submit = form.onSubmit(async (values) => { + const body: CreateLicenseTypeInput = { + key: values.key, + 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, + // The last row by default; the seeded order is EMA's and stays put. + sortOrder: types.length, + }; + if (values.descEn.trim() || values.descAm.trim()) { + body.description = { en: values.descEn.trim(), am: values.descAm.trim() }; + } + if (values.category) body.category = values.category; + if (values.feeNewApplication !== '') body.feeNewApplication = values.feeNewApplication; + + try { + await createType(body).unwrap(); + notify.success(t('licenseTypeConfig.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('licenseTypeConfig.activated', 'Licence type is now accepting applications') + : t('licenseTypeConfig.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('licenseTypeConfig.columns.category', 'Category'), cell: ({ row }) => categoryLabel(row.original.category) }, + { + header: t('licenseTypeConfig.columns.family', 'Family'), + cell: ({ row }) => familyOptions.find((f) => f.value === row.original.familyKind)?.label ?? row.original.familyKind, + }, + { header: t('licenseTypeConfig.columns.prefix', 'Prefix'), cell: ({ row }) => row.original.certificatePrefix }, + { + header: t('licenseTypeConfig.columns.status', 'Status'), + size: 110, + cell: ({ row }) => ( + + {row.original.isActive ? t('licenseTypeConfig.active', 'Active') : t('licenseTypeConfig.inactive', 'Inactive')} + + ), + }, + { + header: '', + label: t('licenseTypeConfig.columns.actions', 'Actions'), + size: 140, + cell: ({ row }) => ( + setPendingToggle(row.original)} + label={row.original.isActive ? t('licenseTypeConfig.deactivate', 'Deactivate') : t('licenseTypeConfig.activate', 'Activate')} + aria-label={t('licenseTypeConfig.toggleAria', 'Toggle whether this licence type accepts applications')} + /> + ), + }, + ]; + + const page = paginate(types); + + return ( + + }> + + {t( + 'licenseTypeConfig.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('licenseTypeConfig.goToRequirements', 'Certificate requirements')} + + {' · '} + + {t('licenseTypeConfig.goToFees', 'Payment configuration')} + + + + + + + + + + + + + + + +
+ + + form.setFieldValue('key', e.currentTarget.value.toUpperCase())} + /> + + + + + + +