feat: add License Types management tab for creating and editing license configurations

This commit is contained in:
estifanos
2026-08-31 12:19:50 +00:00
parent 6f141baca9
commit b48fe86ea7
6 changed files with 541 additions and 0 deletions

View File

@@ -0,0 +1,463 @@
import { useCallback, useState } from "react";
import {
Alert,
Badge,
Button,
Group,
Modal,
NumberInput,
Select,
Stack,
Switch,
Textarea,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { IconInfoCircle, IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
AdvancedTable,
ModalFooter,
notify,
useErrorHandler,
type AdvancedColumn,
} from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, usePermissions } from "@ema-platform/auth";
import {
useCreateLicenseTypeMutation,
useGetLicenseTypesQuery,
useLocalized,
useUpdateLicenseTypeMutation,
type FamilyKind,
type LicenseCategory,
type LicenseType,
} from "@ema-platform/api";
const CATEGORY_OPTIONS: { value: LicenseCategory; label: string }[] = [
{ value: "CARGO_FREIGHT", label: "Cargo & freight" },
{ value: "SHIPPING_AGENCY", label: "Shipping agency" },
{ value: "INVESTMENT", label: "Investment" },
{ value: "MARITIME_PERSONNEL", label: "Maritime personnel" },
{ value: "VESSEL_SERVICES", label: "Vessel services" },
{ value: "WAIVER_SERVICES", label: "Waiver services" },
];
const FAMILY_OPTIONS: { value: FamilyKind; label: string }[] = [
{ value: "LOGISTICS_LICENSE", label: "Licence" },
{ value: "CERTIFICATE", label: "Certificate" },
{ value: "DOCUMENT", label: "Document" },
];
interface FormValues {
key: string;
nameEn: string;
nameAm: string;
descEn: string;
descAm: string;
category: LicenseCategory;
familyKind: FamilyKind;
certificatePrefix: string;
sortOrder: number;
isActive: boolean;
}
const EMPTY: FormValues = {
key: "",
nameEn: "",
nameAm: "",
descEn: "",
descAm: "",
category: "CARGO_FREIGHT",
familyKind: "LOGISTICS_LICENSE",
certificatePrefix: "",
// Dark until it is configured: an active type with no form schema and no
// document requirements is immediately visible to every applicant.
isActive: false,
sortOrder: 0,
};
/**
* The licence type catalogue itself — the one piece of licensing
* configuration that had no screen.
*
* A licence type is a database row, not a code artefact (BR-MTO-020), but
* until now the row could only be created by a seed or by calling the API
* directly, while everything *after* creation had an editor. This is that
* missing write side, and nothing more: the form, document slots, staff
* rules and behaviour flags stay on Certificate requirements, the fees on
* Payment configuration, and the certificate design on the designer, so no
* setting is editable in two places.
*
* There is no delete. `license_applications.license_type_id` is RESTRICT and
* a type with files against it cannot be removed — a retired type is one
* switched inactive, which takes it out of the catalogue and leaves the
* applications that reference it intact.
*/
export function LicenseTypesTab() {
const { t } = useTranslation();
const localized = useLocalized();
const { can } = usePermissions();
const { handleError } = useErrorHandler();
const canCreate = can([LICENSE_PERMISSIONS.CREATE_LICENSE_TYPE]);
const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]);
const { data, isFetching, refetch } = useGetLicenseTypesQuery();
const [createLicenseType, { isLoading: isCreating }] =
useCreateLicenseTypeMutation();
const [updateLicenseType, { isLoading: isUpdating }] =
useUpdateLicenseTypeMutation();
const [editing, setEditing] = useState<LicenseType | null>(null);
const [showForm, setShowForm] = useState(false);
const form = useForm<FormValues>({
initialValues: EMPTY,
validate: {
// The key is the identifier half the platform branches on — the
// certificate designer, the queue's type facet and the portal's
// `/licensing/:key/apply` route all address a type by it.
key: (v) =>
/^[A-Z][A-Z0-9_]{2,63}$/.test(v)
? null
: t(
"configuration.licenseTypes.keyInvalid",
"Upper-case letters, digits and underscores, 364 characters",
),
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
certificatePrefix: (v) =>
v && v.length <= 12
? null
: t(
"configuration.licenseTypes.prefixInvalid",
"Required, at most 12 characters",
),
},
});
const resetForm = useCallback(() => {
setEditing(null);
setShowForm(false);
form.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openCreate = useCallback(() => {
setEditing(null);
form.setValues(EMPTY);
setShowForm(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openEdit = useCallback((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",
certificatePrefix: licenseType.certificatePrefix,
sortOrder: licenseType.sortOrder ?? 0,
isActive: licenseType.isActive,
});
setShowForm(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = form.onSubmit(async (values) => {
const body = {
name: { en: values.nameEn, am: values.nameAm },
description:
values.descEn || values.descAm
? { en: values.descEn, am: values.descAm }
: undefined,
category: values.category,
familyKind: values.familyKind,
certificatePrefix: values.certificatePrefix,
sortOrder: values.sortOrder,
isActive: values.isActive,
};
try {
if (editing) {
// The key is left out on purpose: applications, licences and the
// portal's own routes address a type by it, so renaming one in place
// would strand everything already pointing at the old name.
await updateLicenseType({ id: editing.id, ...body }).unwrap();
notify.success(t("configuration.updated"));
} else {
await createLicenseType({ key: values.key, ...body }).unwrap();
notify.success(t("configuration.created"));
}
resetForm();
} catch (e) {
handleError(e);
}
});
const items = [...(data?.items ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const columns: AdvancedColumn<LicenseType>[] = [
{
header: t("configuration.licenseTypes.key", "Key"),
cell: ({ row }) => row.original.key,
},
{
header: t("configuration.name"),
cell: ({ row }) => localized(row.original.name),
},
{
header: t("configuration.licenseTypes.category", "Category"),
cell: ({ row }) =>
CATEGORY_OPTIONS.find((c) => c.value === row.original.category)?.label ??
row.original.category,
},
{
header: t("configuration.licenseTypes.familyKind", "Kind"),
cell: ({ row }) =>
FAMILY_OPTIONS.find((f) => f.value === row.original.familyKind)?.label ??
row.original.familyKind,
},
{
header: t("configuration.licenseTypes.prefix", "Prefix"),
cell: ({ row }) => row.original.certificatePrefix,
},
{
header: t("configuration.licenseTypes.status", "Status"),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.isActive ? "teal" : "gray"}
>
{row.original.isActive
? t("configuration.licenseTypes.active", "Active")
: t("configuration.licenseTypes.inactive", "Inactive")}
</Badge>
),
},
{
header: "actions",
size: 90,
cell: ({ row }) => (
<Button
variant="subtle"
size="xs"
onClick={() => openEdit(row.original)}
disabled={!canEdit}
>
{t("configuration.edit", "Edit")}
</Button>
),
},
];
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-end">
<Title order={4}>
{t("configuration.licenseTypesTab", "Licence types")}
</Title>
<Tooltip
label={t(
"configuration.licenseTypes.noPermission",
"You do not have permission to create licence types.",
)}
disabled={canCreate}
>
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={16} />}
onClick={openCreate}
disabled={!canCreate}
>
{t("configuration.licenseTypes.add", "Add licence type")}
</Button>
</Tooltip>
</Group>
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
title={t(
"configuration.licenseTypes.nextStepsTitle",
"After creating a type",
)}
>
{t(
"configuration.licenseTypes.nextSteps",
"Configure its form, document requirements and behaviour on Certificate requirements, its fees on Payment configuration, and its certificate design in the designer — then switch it active here.",
)}
</Alert>
<AdvancedTable
columns={columns}
data={items}
tableName={t("configuration.licenseTypesTab", "Licence types")}
itemCount={items.length}
// The catalogue is a handful of rows and arrives in one response, so
// it is shown whole rather than paged.
pageIndex={0}
onPageChange={() => undefined}
pageSize={items.length || 10}
refresh={refetch}
isLoading={isFetching}
/>
<Modal
opened={showForm}
onClose={resetForm}
title={
editing
? t("configuration.licenseTypes.edit", "Edit licence type")
: t("configuration.licenseTypes.add", "Add licence type")
}
size="lg"
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label={t("configuration.licenseTypes.key", "Key")}
description={t(
"configuration.licenseTypes.keyHint",
"Permanent identifier, e.g. CUSTOMS_BROKER. Cannot be changed once applications reference it.",
)}
placeholder="CUSTOMS_BROKER"
{...form.getInputProps("key")}
onChange={(e) =>
form.setFieldValue("key", e.currentTarget.value.toUpperCase())
}
disabled={Boolean(editing)}
size="sm"
/>
<Group grow align="flex-start">
<TextInput
label={t("configuration.nameEn", "Name (English)")}
{...form.getInputProps("nameEn")}
size="sm"
/>
<TextInput
label={t("configuration.nameAm", "Name (Amharic)")}
{...form.getInputProps("nameAm")}
size="sm"
/>
</Group>
<Group grow align="flex-start">
<Textarea
label={t("configuration.descEn", "Description (English)")}
autosize
minRows={2}
{...form.getInputProps("descEn")}
size="sm"
/>
<Textarea
label={t("configuration.descAm", "Description (Amharic)")}
autosize
minRows={2}
{...form.getInputProps("descAm")}
size="sm"
/>
</Group>
<Group grow align="flex-start">
<Select
label={t("configuration.licenseTypes.category", "Category")}
description={t(
"configuration.licenseTypes.categoryHint",
"Decides which applicants may apply and which officer positions can act on it.",
)}
data={CATEGORY_OPTIONS}
allowDeselect={false}
{...form.getInputProps("category")}
size="sm"
/>
<Select
label={t("configuration.licenseTypes.familyKind", "Kind")}
description={t(
"configuration.licenseTypes.familyKindHint",
"Licence, certificate or document. Drives the wording, the applicant catalogue and whether the queue shows a company.",
)}
data={FAMILY_OPTIONS}
allowDeselect={false}
{...form.getInputProps("familyKind")}
size="sm"
/>
</Group>
<Group grow align="flex-start">
<TextInput
label={t(
"configuration.licenseTypes.prefix",
"Certificate prefix",
)}
description={t(
"configuration.licenseTypes.prefixHint",
"Front of every application and certificate number, e.g. CB → CB-2026-000042.",
)}
placeholder="CB"
maxLength={12}
{...form.getInputProps("certificatePrefix")}
onChange={(e) =>
form.setFieldValue(
"certificatePrefix",
e.currentTarget.value.toUpperCase(),
)
}
size="sm"
/>
<NumberInput
label={t("configuration.sortOrder", "Order")}
min={0}
allowNegative={false}
{...form.getInputProps("sortOrder")}
size="sm"
/>
</Group>
<Switch
checked={form.values.isActive}
onChange={(e) =>
form.setFieldValue("isActive", e.currentTarget.checked)
}
label={t(
"configuration.licenseTypes.isActive",
"Visible to applicants",
)}
description={t(
"configuration.licenseTypes.isActiveHint",
"Leave off until the form and document requirements are configured.",
)}
/>
<ModalFooter>
<Button variant="default" onClick={resetForm} size="sm">
{t("configuration.cancel")}
</Button>
<Button
type="submit"
size="sm"
loading={isCreating || isUpdating}
disabled={editing ? !canEdit : !canCreate}
>
{editing
? t("configuration.update")
: t("configuration.create", "Create")}
</Button>
</ModalFooter>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -23,6 +23,7 @@ import {
IconCertificate,
IconHash,
IconInfoCircle,
IconLicense,
IconAnchor,
IconId,
} from "@tabler/icons-react";
@@ -41,6 +42,7 @@ import { LocationPage } from "../../../location/pages/LocationPage";
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
import { LicenseTypesTab } from "./LicenseTypesTab";
import { RankDepartmentTab } from "./RankDepartmentTab";
import {
useGetOrganizationsQuery,
@@ -404,6 +406,9 @@ export function ConfigurationPage() {
>
{t("certification.title")}
</Tabs.Tab>
<Tabs.Tab value="licenseTypes" leftSection={<IconLicense size={16} />}>
{t("configuration.licenseTypesTab", "Licence types")}
</Tabs.Tab>
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
{t("numberFormat.title", "Number Formats")}
</Tabs.Tab>
@@ -427,6 +432,10 @@ export function ConfigurationPage() {
<CertificationPage />
</Tabs.Panel>
<Tabs.Panel value="licenseTypes" pt="md">
<LicenseTypesTab />
</Tabs.Panel>
<Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab />
</Tabs.Panel>

View File

@@ -853,6 +853,7 @@ export const am: Translations = {
configuration: {
title: "ውቅረት",
personalDocumentsTab: "የግል ሰነዶች",
licenseTypesTab: "የፈቃድ ዓይነቶች",
departments: "ክፍሎች",
professions: "ሙያዎች",
departmentsList: "ክፍሎች",

View File

@@ -858,6 +858,7 @@ export const en = {
configuration: {
title: 'Configuration',
personalDocumentsTab: 'Personal Documents',
licenseTypesTab: 'Licence types',
departments: 'Departments',
professions: 'Professions',
departmentsList: 'Departments',