mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
feat: add License Types management tab for creating and editing license configurations
This commit is contained in:
@@ -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, 3–64 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -853,6 +853,7 @@ export const am: Translations = {
|
||||
configuration: {
|
||||
title: "ውቅረት",
|
||||
personalDocumentsTab: "የግል ሰነዶች",
|
||||
licenseTypesTab: "የፈቃድ ዓይነቶች",
|
||||
departments: "ክፍሎች",
|
||||
professions: "ሙያዎች",
|
||||
departmentsList: "ክፍሎች",
|
||||
|
||||
@@ -858,6 +858,7 @@ export const en = {
|
||||
configuration: {
|
||||
title: 'Configuration',
|
||||
personalDocumentsTab: 'Personal Documents',
|
||||
licenseTypesTab: 'Licence types',
|
||||
departments: 'Departments',
|
||||
professions: 'Professions',
|
||||
departmentsList: 'Departments',
|
||||
|
||||
Reference in New Issue
Block a user