mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 14:15:45 +00:00
feat: add LicenseTypesTab management component and extend license API types with service and workflow configurations
This commit is contained in:
@@ -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<LicenseType | null>(null);
|
||||
const [pendingToggle, setPendingToggle] = useState<LicenseType | null>(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<FormValues>({
|
||||
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<LicenseTypeCreate, '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,
|
||||
};
|
||||
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 (
|
||||
<ErrorState
|
||||
title={t('certReq.loadFailed', 'Could not load licence types')}
|
||||
description={extractErrorMessage(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isLoading) return <PageLoader label={t('certReq.loading', 'Loading licence types…')} height={300} />;
|
||||
|
||||
const canToggle = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]);
|
||||
|
||||
const columns: AdvancedColumn<LicenseType>[] = [
|
||||
{ header: t('configuration.name'), cell: ({ row }) => localized(row.original.name) },
|
||||
{ header: t('configuration.key', 'Key'), cell: ({ row }) => <Text ff="monospace" size="sm">{row.original.key}</Text> },
|
||||
{ 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 }) => (
|
||||
<Badge color={row.original.isActive ? 'green' : 'gray'} variant="light">
|
||||
{row.original.isActive ? t('configuration.licenseTypes.active', 'Active') : t('configuration.licenseTypes.inactive', 'Inactive')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('configuration.licenseTypes.columns.actions', 'Actions'),
|
||||
size: 220,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)} disabled={!canToggle}>
|
||||
{t('configuration.edit', 'Edit')}
|
||||
</Button>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={row.original.isActive}
|
||||
disabled={!canToggle || isToggling}
|
||||
onChange={() => 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',
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const page = paginate(types);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
<Text size="sm">
|
||||
{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.',
|
||||
)}{' '}
|
||||
<Anchor component={Link} to="/certificate-requirements" size="sm">
|
||||
{t('configuration.licenseTypes.goToRequirements', 'Certificate requirements')}
|
||||
</Anchor>
|
||||
{' · '}
|
||||
<Anchor component={Link} to="/payment-config" size="sm">
|
||||
{t('configuration.licenseTypes.goToFees', 'Payment configuration')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_LICENSE_TYPE]} hideOnly>
|
||||
<Button variant="light" size="sm" leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
||||
{t('configuration.licenseTypes.add', 'Add licence type')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName="configuration-license-types"
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('configuration.licenseTypes.empty', 'No licence types yet')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={showForm}
|
||||
onClose={closeForm}
|
||||
title={
|
||||
editing
|
||||
? t('configuration.licenseTypes.edit', 'Edit licence type')
|
||||
: t('configuration.licenseTypes.add', 'Add licence type')
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<TextInput
|
||||
label={t('configuration.key', 'Key')}
|
||||
description={t('configuration.licenseTypes.keyHint', 'Stable identifier, upper snake case. Cannot change once applications exist.')}
|
||||
placeholder="PORT_AGENT"
|
||||
required
|
||||
{...form.getInputProps('key')}
|
||||
onChange={(e) => form.setFieldValue('key', e.currentTarget.value.toUpperCase())}
|
||||
disabled={Boolean(editing)}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('configuration.licenseTypes.prefix', 'Certificate number prefix')}
|
||||
description={t('configuration.licenseTypes.prefixHint', 'e.g. FF → FF-2026-000123')}
|
||||
placeholder="PA"
|
||||
required
|
||||
maxLength={12}
|
||||
{...form.getInputProps('certificatePrefix')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<TextInput label={t('configuration.nameEn')} required {...form.getInputProps('nameEn')} />
|
||||
<TextInput label={t('configuration.nameAm')} required {...form.getInputProps('nameAm')} />
|
||||
<Textarea label={t('configuration.descEn')} autosize minRows={2} {...form.getInputProps('descEn')} />
|
||||
<Textarea label={t('configuration.descAm')} autosize minRows={2} {...form.getInputProps('descAm')} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Select
|
||||
label={t('configuration.licenseTypes.columns.category', 'Category')}
|
||||
description={t('configuration.licenseTypes.categoryHint', 'The group the applicant browses by.')}
|
||||
data={categoryOptions}
|
||||
clearable
|
||||
searchable
|
||||
{...form.getInputProps('category')}
|
||||
/>
|
||||
<Select
|
||||
label={t('configuration.licenseTypes.columns.family', 'Family')}
|
||||
description={t('configuration.licenseTypes.familyHint', 'Decides which desk owns it and which portal catalogue lists it.')}
|
||||
data={familyOptions}
|
||||
allowDeselect={false}
|
||||
{...form.getInputProps('familyKind')}
|
||||
/>
|
||||
<Select
|
||||
label={t('certReq.behavior.serviceKind', 'Service kind')}
|
||||
data={serviceOptions}
|
||||
allowDeselect={false}
|
||||
{...form.getInputProps('serviceKind')}
|
||||
/>
|
||||
<Select
|
||||
label={t('certReq.behavior.workflowProfile', 'Workflow profile')}
|
||||
data={workflowOptions}
|
||||
allowDeselect={false}
|
||||
{...form.getInputProps('workflowProfile')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<NumberInput
|
||||
label={t('configuration.licenseTypes.fee', 'New application fee')}
|
||||
description={t('configuration.licenseTypes.feeHint', 'Leave blank for no charge.')}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
{...form.getInputProps('feeNewApplication')}
|
||||
/>
|
||||
<TextInput label={t('configuration.licenseTypes.currency', 'Currency')} maxLength={8} {...form.getInputProps('feeCurrency')} />
|
||||
<NumberInput
|
||||
label={t('configuration.licenseTypes.validity', 'Validity (months)')}
|
||||
min={6}
|
||||
max={240}
|
||||
allowDecimal={false}
|
||||
required
|
||||
{...form.getInputProps('validityMonths')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap="lg">
|
||||
<Checkbox
|
||||
label={t('configuration.licenseTypes.issuesCertificate', 'Issues a certificate')}
|
||||
{...form.getInputProps('issuesCertificate', { type: 'checkbox' })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('configuration.licenseTypes.renewalEnabled', 'Renewable')}
|
||||
{...form.getInputProps('renewalEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('configuration.licenseTypes.inspectionRequired', 'Inspection required')}
|
||||
{...form.getInputProps('inspectionRequired', { type: 'checkbox' })}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" size="sm" onClick={closeForm}>
|
||||
{t('configuration.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
|
||||
{editing ? t('configuration.update') : t('configuration.create')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={pendingToggle !== null}
|
||||
onClose={() => setPendingToggle(null)}
|
||||
title={
|
||||
pendingToggle?.isActive
|
||||
? t('configuration.licenseTypes.deactivateTitle', 'Deactivate licence type')
|
||||
: t('configuration.licenseTypes.activateTitle', 'Activate licence type')
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<Text size="sm" mb="md">
|
||||
{pendingToggle?.isActive
|
||||
? t('configuration.licenseTypes.deactivateText', {
|
||||
name: pendingToggle ? localized(pendingToggle.name) : '',
|
||||
defaultValue:
|
||||
'{{name}} will disappear from the applicant catalogue. Applications already in progress continue unaffected.',
|
||||
})
|
||||
: t('configuration.licenseTypes.activateText', {
|
||||
name: pendingToggle ? localized(pendingToggle.name) : '',
|
||||
defaultValue: '{{name}} will be offered to applicants again.',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" size="sm" onClick={() => setPendingToggle(null)}>
|
||||
{t('configuration.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" color={pendingToggle?.isActive ? 'red' : 'green'} loading={isToggling} onClick={confirmToggle}>
|
||||
{pendingToggle?.isActive
|
||||
? t('configuration.licenseTypes.deactivate', 'Deactivate')
|
||||
: t('configuration.licenseTypes.activate', 'Activate')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,557 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
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 {
|
||||
extractErrorMessage,
|
||||
useCreateLicenseTypeMutation,
|
||||
useGetLicenseTypesQuery,
|
||||
useLocalized,
|
||||
useUpdateLicenseStatusMutation,
|
||||
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();
|
||||
// The narrow `PATCH :id/status` rather than the whole-row PUT: switching a
|
||||
// type off is the one change made in a hurry, and it must not carry every
|
||||
// other field of the form along with it.
|
||||
const [updateLicenseStatus, { isLoading: isToggling }] =
|
||||
useUpdateLicenseStatusMutation();
|
||||
|
||||
const [editing, setEditing] = useState<LicenseType | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [pendingToggle, setPendingToggle] = useState<LicenseType | null>(null);
|
||||
|
||||
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 confirmToggle = useCallback(async () => {
|
||||
if (!pendingToggle) return;
|
||||
const next = !pendingToggle.isActive;
|
||||
try {
|
||||
await updateLicenseStatus({ 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")));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingToggle, updateLicenseStatus]);
|
||||
|
||||
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: 170,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => openEdit(row.original)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t("configuration.edit", "Edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color={row.original.isActive ? "red" : "teal"}
|
||||
onClick={() => setPendingToggle(row.original)}
|
||||
disabled={!canEdit || isToggling}
|
||||
>
|
||||
{row.original.isActive
|
||||
? t("configuration.licenseTypes.deactivate", "Deactivate")
|
||||
: t("configuration.licenseTypes.activate", "Activate")}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
<Modal
|
||||
opened={pendingToggle !== null}
|
||||
onClose={() => setPendingToggle(null)}
|
||||
title={
|
||||
pendingToggle?.isActive
|
||||
? t(
|
||||
"configuration.licenseTypes.deactivateTitle",
|
||||
"Deactivate licence type",
|
||||
)
|
||||
: t(
|
||||
"configuration.licenseTypes.activateTitle",
|
||||
"Activate licence type",
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<Text size="sm" mb="md">
|
||||
{pendingToggle?.isActive
|
||||
? t("configuration.licenseTypes.deactivateText", {
|
||||
name: pendingToggle ? localized(pendingToggle.name) : "",
|
||||
defaultValue:
|
||||
"{{name}} will disappear from the applicant catalogue. Applications already in progress continue unaffected.",
|
||||
})
|
||||
: t("configuration.licenseTypes.activateText", {
|
||||
name: pendingToggle ? localized(pendingToggle.name) : "",
|
||||
defaultValue: "{{name}} will be offered to applicants again.",
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setPendingToggle(null)}
|
||||
>
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={pendingToggle?.isActive ? "red" : "teal"}
|
||||
loading={isToggling}
|
||||
onClick={confirmToggle}
|
||||
>
|
||||
{pendingToggle?.isActive
|
||||
? t("configuration.licenseTypes.deactivate", "Deactivate")
|
||||
: t("configuration.licenseTypes.activate", "Activate")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +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 { LicenseTypesTab } from "../../components/LicenseTypesTab";
|
||||
import { RankDepartmentTab } from "./RankDepartmentTab";
|
||||
import {
|
||||
useGetOrganizationsQuery,
|
||||
|
||||
@@ -920,6 +920,51 @@ export const am: Translations = {
|
||||
nextStepsTitle: "ዓይነቱን ከፈጠሩ በኋላ",
|
||||
nextSteps:
|
||||
"ቅጹን፣ የሰነድ መስፈርቶቹንና ባህሪውን በምስክር ወረቀት መስፈርቶች፣ ክፍያዎቹን በክፍያ ውቅረት፣ የምስክር ወረቀት ንድፉን ደግሞ በንድፍ ሰሪው ያዘጋጁ — ከዚያ እዚህ ንቁ ያድርጉት።",
|
||||
empty: "እስካሁን ምንም የፈቃድ አይነቶች የሉም",
|
||||
notice:
|
||||
"አዲስ አይነት ባዶ ሆኖ ይጀምራል። ከፈጠሩ በኋላ ቅጹንና የሰነድ መስፈርቶቹን በምስክር ወረቀት መስፈርቶች ስር፣ ክፍያዎቹን ደግሞ በክፍያ ውቅረት ስር ያዘጋጁ።",
|
||||
goToRequirements: "የምስክር ወረቀት መስፈርቶች",
|
||||
goToFees: "የክፍያ ውቅረት",
|
||||
created: "የፈቃድ አይነት ተፈጥሯል። በመቀጠል ቅጹን፣ ሰነዶቹንና ክፍያዎቹን ያዘጋጁ።",
|
||||
toggleAria: "ይህ የፈቃድ አይነት ማመልከቻ መቀበል አለመቀበሉን ይቀያይሩ",
|
||||
familyHint: "የትኛው ክፍል እንደሚያስተዳድረው እና በየትኛው የፖርታል ካታሎግ እንደሚታይ ይወስናል።",
|
||||
fee: "የአዲስ ማመልከቻ ክፍያ",
|
||||
feeHint: "ክፍያ ከሌለው ባዶ ይተዉት።",
|
||||
currency: "ምንዛሪ",
|
||||
validity: "የአገልግሎት ጊዜ (በወራት)",
|
||||
issuesCertificate: "የምስክር ወረቀት ይሰጣል",
|
||||
renewalEnabled: "ሊታደስ የሚችል",
|
||||
inspectionRequired: "ምርመራ ያስፈልጋል",
|
||||
columns: {
|
||||
category: "ምድብ",
|
||||
family: "ቤተሰብ",
|
||||
prefix: "ቅድመ ቅጥያ",
|
||||
status: "ሁኔታ",
|
||||
actions: "እርምጃዎች",
|
||||
},
|
||||
family: {
|
||||
LOGISTICS_LICENSE: "የሎጂስቲክስ ፈቃድ",
|
||||
CERTIFICATE: "የባህርተኛ የምስክር ወረቀት",
|
||||
DOCUMENT: "የማንነት / ህጋዊ ሰነድ",
|
||||
},
|
||||
service: {
|
||||
LICENSE: "ፈቃድ",
|
||||
REGISTRATION: "ምዝገባ",
|
||||
},
|
||||
workflow: {
|
||||
STANDARD: "መደበኛ (ግምገማ → ምዘና → ምርመራ → ማጽደቅ)",
|
||||
REGISTRATION: "ምዝገባ (ግምገማ → ማጽደቅ)",
|
||||
},
|
||||
validation: {
|
||||
keyRequired: "ቁልፍ ያስፈልጋል",
|
||||
keyTooLong: "ቁልፍ ከ64 ቁምፊዎች መብለጥ የለበትም",
|
||||
keyFormat: "ፊደላት፣ አሃዞችና ከስር መስመር ብቻ ይጠቀሙ፣ ለምሳሌ PORT_AGENT",
|
||||
keyTaken: "በዚህ ቁልፍ የፈቃድ አይነት ቀድሞ አለ",
|
||||
prefixRequired: "የምስክር ወረቀት ቅድመ ቅጥያ ያስፈልጋል",
|
||||
prefixTooLong: "ቅድመ ቅጥያ ከ12 ቁምፊዎች መብለጥ የለበትም",
|
||||
currencyTooLong: "አጭር የምንዛሪ ኮድ ይጠቀሙ",
|
||||
validityRange: "የአገልግሎት ጊዜ በ6 እና 240 ወራት መካከል መሆን አለበት",
|
||||
},
|
||||
},
|
||||
personalDocumentsTab: "የግል ሰነዶች",
|
||||
departments: "ክፍሎች",
|
||||
|
||||
@@ -926,6 +926,51 @@ export const en = {
|
||||
nextStepsTitle: 'After creating a type',
|
||||
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.',
|
||||
empty: 'No licence types yet',
|
||||
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.',
|
||||
goToRequirements: 'Certificate requirements',
|
||||
goToFees: 'Payment configuration',
|
||||
created: 'Licence type created. Configure its form, documents and fees next.',
|
||||
toggleAria: 'Toggle whether this licence type accepts applications',
|
||||
familyHint: 'Decides which desk owns it and which portal catalogue lists it.',
|
||||
fee: 'New application fee',
|
||||
feeHint: 'Leave blank for no charge.',
|
||||
currency: 'Currency',
|
||||
validity: 'Validity (months)',
|
||||
issuesCertificate: 'Issues a certificate',
|
||||
renewalEnabled: 'Renewable',
|
||||
inspectionRequired: 'Inspection required',
|
||||
columns: {
|
||||
category: 'Category',
|
||||
family: 'Family',
|
||||
prefix: 'Prefix',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
},
|
||||
family: {
|
||||
LOGISTICS_LICENSE: 'Logistics licence',
|
||||
CERTIFICATE: 'Seafarer certificate',
|
||||
DOCUMENT: 'Identity / statutory document',
|
||||
},
|
||||
service: {
|
||||
LICENSE: 'Licence',
|
||||
REGISTRATION: 'Registration',
|
||||
},
|
||||
workflow: {
|
||||
STANDARD: 'Standard (review → evaluation → inspection → approval)',
|
||||
REGISTRATION: 'Registration (review → approval)',
|
||||
},
|
||||
validation: {
|
||||
keyRequired: 'Key is required',
|
||||
keyTooLong: 'Key must be at most 64 characters',
|
||||
keyFormat: 'Use letters, digits and underscores, e.g. PORT_AGENT',
|
||||
keyTaken: 'A licence type with this key already exists',
|
||||
prefixRequired: 'Certificate prefix is required',
|
||||
prefixTooLong: 'Prefix must be at most 12 characters',
|
||||
currencyTooLong: 'Use a short currency code',
|
||||
validityRange: 'Validity must be between 6 and 240 months',
|
||||
},
|
||||
},
|
||||
personalDocumentsTab: 'Personal Documents',
|
||||
departments: 'Departments',
|
||||
|
||||
Reference in New Issue
Block a user