diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/LicenseTypesTab.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/LicenseTypesTab.tsx new file mode 100644 index 000000000..0e82e5644 --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/LicenseTypesTab.tsx @@ -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(null); + const [showForm, setShowForm] = useState(false); + + const form = useForm({ + 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[] = [ + { + 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 }) => ( + + {row.original.isActive + ? t("configuration.licenseTypes.active", "Active") + : t("configuration.licenseTypes.inactive", "Inactive")} + + ), + }, + { + header: "actions", + size: 90, + cell: ({ row }) => ( + + ), + }, + ]; + + return ( + + + + {t("configuration.licenseTypesTab", "Licence types")} + + + + + + + } + 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.", + )} + + + undefined} + pageSize={items.length || 10} + refresh={refetch} + isLoading={isFetching} + /> + + +
+ + + form.setFieldValue("key", e.currentTarget.value.toUpperCase()) + } + disabled={Boolean(editing)} + size="sm" + /> + + + + + + + +