mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 13:05:46 +00:00
Merge pull request #64 from Tria-plc/createNewLicenseType
feat: add license type management and import personal document requirements
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
SegmentedControl,
|
||||
@@ -13,10 +14,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { IconCheck, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetPersonalDocumentsQuery,
|
||||
useLocalized,
|
||||
type ApplicationKind,
|
||||
type DocumentRequirement,
|
||||
@@ -74,7 +76,7 @@ const MIME_OPTIONS = [
|
||||
/** What a new slot accepts until someone widens it. */
|
||||
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
|
||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
export type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
|
||||
/**
|
||||
* Which licences a personal document is asked for.
|
||||
@@ -117,6 +119,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
requirement,
|
||||
initialDraft,
|
||||
defaultApplicationKind,
|
||||
onSave,
|
||||
palette,
|
||||
@@ -130,6 +133,8 @@ export function DocumentRequirementEditorDrawer({
|
||||
onClose: () => void;
|
||||
/** Null = adding a new requirement. */
|
||||
requirement: DocumentRequirement | null;
|
||||
/** Optional initial prefill when adding a new requirement (e.g. from personal docs). */
|
||||
initialDraft?: Partial<DraftRequirement> | null;
|
||||
defaultApplicationKind: ApplicationKind;
|
||||
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
|
||||
palette: FormSchemaPalette | undefined;
|
||||
@@ -142,7 +147,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
/** The licence types this document is already scoped to. */
|
||||
scope?: PersonalScope;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [draft, setDraft] = useState<DraftRequirement>(
|
||||
emptyDraft(defaultApplicationKind, personal),
|
||||
@@ -150,36 +155,84 @@ export function DocumentRequirementEditorDrawer({
|
||||
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
|
||||
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [selectedPersonalKey, setSelectedPersonalKey] = useState<string | null>(null);
|
||||
const isNew = !requirement;
|
||||
|
||||
const { data: personalDocsData } = useGetPersonalDocumentsQuery(
|
||||
{ take: 100, locale: i18n.language === 'am' ? 'am' : 'en' },
|
||||
{ skip: !opened || !isNew || personal },
|
||||
);
|
||||
|
||||
const personalDocOptions = useMemo(
|
||||
() =>
|
||||
(personalDocsData?.items ?? []).map((item) => ({
|
||||
value: item.key,
|
||||
label: `${localized(item.rows[0]?.name) || item.key} (${item.key})`,
|
||||
})),
|
||||
[personalDocsData, localized],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setDraft(
|
||||
requirement
|
||||
? {
|
||||
key: requirement.key,
|
||||
name: { ...requirement.name },
|
||||
description: requirement.description ? { ...requirement.description } : undefined,
|
||||
applicationKind: requirement.applicationKind,
|
||||
mode: requirement.mode,
|
||||
conditionExpression: requirement.conditionExpression,
|
||||
allowedMimeTypes: requirement.allowedMimeTypes,
|
||||
maxSizeMb: requirement.maxSizeMb,
|
||||
requiresValidityDates: requirement.requiresValidityDates,
|
||||
allowMultiple: requirement.allowMultiple,
|
||||
isPersonal: requirement.isPersonal ?? personal,
|
||||
maxFiles: requirement.maxFiles ?? null,
|
||||
sortOrder: requirement.sortOrder,
|
||||
}
|
||||
: emptyDraft(defaultApplicationKind, personal),
|
||||
);
|
||||
if (requirement) {
|
||||
setDraft({
|
||||
key: requirement.key,
|
||||
name: { ...requirement.name },
|
||||
description: requirement.description ? { ...requirement.description } : undefined,
|
||||
applicationKind: requirement.applicationKind,
|
||||
mode: requirement.mode,
|
||||
conditionExpression: requirement.conditionExpression,
|
||||
allowedMimeTypes: requirement.allowedMimeTypes,
|
||||
maxSizeMb: requirement.maxSizeMb,
|
||||
requiresValidityDates: requirement.requiresValidityDates,
|
||||
allowMultiple: requirement.allowMultiple,
|
||||
isPersonal: requirement.isPersonal ?? personal,
|
||||
maxFiles: requirement.maxFiles ?? null,
|
||||
sortOrder: requirement.sortOrder,
|
||||
});
|
||||
setSelectedPersonalKey(null);
|
||||
} else if (initialDraft) {
|
||||
setDraft({
|
||||
...emptyDraft(defaultApplicationKind, personal),
|
||||
...initialDraft,
|
||||
});
|
||||
setSelectedPersonalKey(initialDraft.key ?? null);
|
||||
} else {
|
||||
setDraft(emptyDraft(defaultApplicationKind, personal));
|
||||
setSelectedPersonalKey(null);
|
||||
}
|
||||
setScopeIds(scope);
|
||||
setAppliesToAll(scope.length === 0);
|
||||
setKeyError(null);
|
||||
}
|
||||
// `scope` is a fresh array each render; the opened flag is what gates this.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, requirement, defaultApplicationKind, personal]);
|
||||
}, [opened, requirement, initialDraft, defaultApplicationKind, personal]);
|
||||
|
||||
function handleSelectPersonalDoc(key: string | null) {
|
||||
setSelectedPersonalKey(key);
|
||||
if (!key) return;
|
||||
const found = personalDocsData?.items?.find((item) => item.key === key);
|
||||
if (found && found.rows[0]) {
|
||||
const row = found.rows[0];
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
key: found.key,
|
||||
name: { en: row.name?.en ?? '', am: row.name?.am ?? '' },
|
||||
description: row.description
|
||||
? { en: row.description?.en ?? '', am: row.description?.am ?? '' }
|
||||
: undefined,
|
||||
allowedMimeTypes: row.allowedMimeTypes?.length
|
||||
? [...row.allowedMimeTypes]
|
||||
: d.allowedMimeTypes,
|
||||
maxSizeMb: row.maxSizeMb ?? d.maxSizeMb,
|
||||
maxFiles: row.maxFiles ?? null,
|
||||
requiresValidityDates: row.requiresValidityDates ?? false,
|
||||
allowMultiple: (row.maxFiles ?? 1) !== 1,
|
||||
}));
|
||||
setKeyError(null);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.key.trim()) {
|
||||
@@ -241,6 +294,40 @@ export function DocumentRequirementEditorDrawer({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!personal && isNew && (
|
||||
<Stack gap="xs">
|
||||
<Select
|
||||
label={t('certReq.doc.importFromPersonal', 'Import from personal document')}
|
||||
placeholder={t(
|
||||
'certReq.doc.selectPersonalDoc',
|
||||
'Select a personal document to copy details…',
|
||||
)}
|
||||
data={personalDocOptions}
|
||||
value={selectedPersonalKey}
|
||||
onChange={handleSelectPersonalDoc}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={<IconDownload size={15} />}
|
||||
description={t(
|
||||
'certReq.doc.importPersonalDesc',
|
||||
'Select documents configured in Personal Documents to add as requirements for this licence type.',
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedPersonalKey && (
|
||||
<Alert variant="light" color="teal" icon={<IconCheck size={16} />}>
|
||||
<Text fz="xs">
|
||||
{t(
|
||||
'certReq.doc.importedHelper',
|
||||
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Divider my="xs" />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('certReq.doc.key', 'Key')}
|
||||
placeholder="bank_letter"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconAlertCircle, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { IconAlertCircle, IconDownload, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||
import {
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
import { collectConditionTargets } from '../config/schema-paths';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import { describeCondition } from './ConditionBuilder';
|
||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||
import {
|
||||
DocumentRequirementEditorDrawer,
|
||||
type DraftRequirement,
|
||||
} from './DocumentRequirementEditorDrawer';
|
||||
import { ImportPersonalDocumentsModal } from './ImportPersonalDocumentsModal';
|
||||
|
||||
/** Every kind the server validates a document set for, so each can be configured. */
|
||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE'];
|
||||
@@ -54,6 +58,8 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
|
||||
|
||||
const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null);
|
||||
const [initialDraft, setInitialDraft] = useState<Partial<DraftRequirement> | null>(null);
|
||||
const [importModalKind, setImportModalKind] = useState<ApplicationKind | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
||||
|
||||
const requirements = useMemo(
|
||||
@@ -120,14 +126,27 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
<Card key={kind} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={5}>{t(...KIND_LABEL[kind])}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => setEditorState({ kind, requirement: null })}
|
||||
>
|
||||
{t('certReq.doc.add', 'Add document requirement')}
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
leftSection={<IconDownload size={13} />}
|
||||
onClick={() => setImportModalKind(kind)}
|
||||
>
|
||||
{t('certReq.doc.importPersonal', 'Import from personal documents')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => {
|
||||
setInitialDraft(null);
|
||||
setEditorState({ kind, requirement: null });
|
||||
}}
|
||||
>
|
||||
{t('certReq.doc.add', 'Add document requirement')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
@@ -180,8 +199,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
|
||||
<DocumentRequirementEditorDrawer
|
||||
opened={editorState !== null}
|
||||
onClose={() => setEditorState(null)}
|
||||
onClose={() => {
|
||||
setEditorState(null);
|
||||
setInitialDraft(null);
|
||||
}}
|
||||
requirement={editorState?.requirement ?? null}
|
||||
initialDraft={initialDraft}
|
||||
defaultApplicationKind={editorState?.kind ?? 'NEW'}
|
||||
onSave={handleSave}
|
||||
palette={palette}
|
||||
@@ -189,6 +212,28 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
saving={creating || updating}
|
||||
/>
|
||||
|
||||
{importModalKind && (
|
||||
<ImportPersonalDocumentsModal
|
||||
opened={importModalKind !== null}
|
||||
onClose={() => setImportModalKind(null)}
|
||||
licenseType={licenseType}
|
||||
applicationKind={importModalKind}
|
||||
existingKeys={requirements
|
||||
.filter((r) => r.applicationKind === importModalKind)
|
||||
.map((r) => r.key)}
|
||||
currentMaxSortOrder={requirements
|
||||
.filter((r) => r.applicationKind === importModalKind)
|
||||
.reduce((max, r) => Math.max(max, r.sortOrder ?? 0), 0)}
|
||||
onCustomizeAndAdd={(draft) => {
|
||||
setInitialDraft(draft);
|
||||
setEditorState({
|
||||
kind: (draft.applicationKind as ApplicationKind) ?? importModalKind,
|
||||
requirement: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal opened={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm">
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" variant="light">
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAdjustments,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconDownload,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import {
|
||||
useCreateDocumentRequirementMutation,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPersonalDocumentsQuery,
|
||||
useLocalized,
|
||||
type ApplicationKind,
|
||||
type DocumentRequirement,
|
||||
type LicenseType,
|
||||
type PersonalDocumentGroup,
|
||||
} from '@ema-platform/api';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import type { DraftRequirement } from './DocumentRequirementEditorDrawer';
|
||||
|
||||
const MIME_LABELS: Record<string, string> = {
|
||||
'application/pdf': 'PDF',
|
||||
'application/msword': 'DOC',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
|
||||
'application/vnd.ms-excel': 'XLS',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
|
||||
};
|
||||
|
||||
function shortMime(mime: string): string {
|
||||
return MIME_LABELS[mime] ?? mime.split('/')[1]?.toUpperCase() ?? mime;
|
||||
}
|
||||
|
||||
interface ImportPersonalDocumentsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
licenseType: LicenseType;
|
||||
applicationKind: ApplicationKind;
|
||||
existingKeys: string[];
|
||||
currentMaxSortOrder: number;
|
||||
onCustomizeAndAdd: (draft: Partial<DraftRequirement>) => void;
|
||||
}
|
||||
|
||||
export function ImportPersonalDocumentsModal({
|
||||
opened,
|
||||
onClose,
|
||||
licenseType,
|
||||
applicationKind,
|
||||
existingKeys,
|
||||
currentMaxSortOrder,
|
||||
onCustomizeAndAdd,
|
||||
}: ImportPersonalDocumentsModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const run = useRequirementActions();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'scoped'>('all');
|
||||
const [importMode, setImportMode] = useState<DocumentRequirement['mode']>('ALWAYS');
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const { data: personalDocsData, isLoading, isError, refetch } = useGetPersonalDocumentsQuery({
|
||||
take: 100,
|
||||
locale: i18n.language === 'am' ? 'am' : 'en',
|
||||
});
|
||||
|
||||
const [createRequirement, { isLoading: isCreating }] = useCreateDocumentRequirementMutation();
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return (personalDocsData?.items ?? []).map((group) => {
|
||||
const scopeIds = group.rows
|
||||
.map((r) => r.licenseTypeId)
|
||||
.filter((id): id is string => id !== null);
|
||||
const isGlobal = scopeIds.length === 0;
|
||||
const appliesToCurrentType = isGlobal || scopeIds.includes(licenseType.id);
|
||||
const isAlreadyAdded = existingKeys.includes(group.key);
|
||||
|
||||
return {
|
||||
...group,
|
||||
scopeIds,
|
||||
isGlobal,
|
||||
appliesToCurrentType,
|
||||
isAlreadyAdded,
|
||||
mainRow: group.rows[0],
|
||||
};
|
||||
});
|
||||
}, [personalDocsData, licenseType.id, existingKeys]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return groups.filter((item) => {
|
||||
if (scopeFilter === 'scoped' && !item.appliesToCurrentType) {
|
||||
return false;
|
||||
}
|
||||
if (!q) return true;
|
||||
const nameEn = item.mainRow.name.en?.toLowerCase() ?? '';
|
||||
const nameAm = item.mainRow.name.am?.toLowerCase() ?? '';
|
||||
const key = item.key.toLowerCase();
|
||||
return nameEn.includes(q) || nameAm.includes(q) || key.includes(q);
|
||||
});
|
||||
}, [groups, search, scopeFilter]);
|
||||
|
||||
const selectableKeys = useMemo(
|
||||
() => filteredGroups.filter((g) => !g.isAlreadyAdded).map((g) => g.key),
|
||||
[filteredGroups],
|
||||
);
|
||||
|
||||
const allSelected =
|
||||
selectableKeys.length > 0 && selectableKeys.every((k) => selectedKeys.includes(k));
|
||||
const someSelected =
|
||||
selectableKeys.some((k) => selectedKeys.includes(k)) && !allSelected;
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected) {
|
||||
setSelectedKeys((prev) => prev.filter((k) => !selectableKeys.includes(k)));
|
||||
} else {
|
||||
setSelectedKeys((prev) => Array.from(new Set([...prev, ...selectableKeys])));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectOne(key: string) {
|
||||
setSelectedKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
|
||||
async function handleBatchImport() {
|
||||
const itemsToImport = groups.filter(
|
||||
(g) => selectedKeys.includes(g.key) && !g.isAlreadyAdded,
|
||||
);
|
||||
if (itemsToImport.length === 0) return;
|
||||
|
||||
const ok = await run(
|
||||
() =>
|
||||
Promise.all(
|
||||
itemsToImport.map((item, index) =>
|
||||
createRequirement({
|
||||
key: item.key,
|
||||
name: item.mainRow.name,
|
||||
description: item.mainRow.description,
|
||||
applicationKind,
|
||||
mode: importMode,
|
||||
allowedMimeTypes:
|
||||
item.mainRow.allowedMimeTypes ?? ['application/pdf', 'image/jpeg', 'image/png'],
|
||||
maxSizeMb: item.mainRow.maxSizeMb ?? 5,
|
||||
requiresValidityDates: item.mainRow.requiresValidityDates ?? false,
|
||||
allowMultiple: (item.mainRow.maxFiles ?? 1) !== 1,
|
||||
isPersonal: false,
|
||||
maxFiles: item.mainRow.maxFiles ?? null,
|
||||
sortOrder: currentMaxSortOrder + index + 1,
|
||||
licenseTypeId: licenseType.id,
|
||||
}).unwrap(),
|
||||
),
|
||||
),
|
||||
t('certReq.doc.importSuccess', 'Imported {{count}} personal document(s)', {
|
||||
count: itemsToImport.length,
|
||||
}),
|
||||
);
|
||||
|
||||
if (ok) {
|
||||
setSelectedKeys([]);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomizeRow(item: (typeof groups)[number]) {
|
||||
onCustomizeAndAdd({
|
||||
key: item.key,
|
||||
name: { ...item.mainRow.name },
|
||||
description: item.mainRow.description ? { ...item.mainRow.description } : undefined,
|
||||
applicationKind,
|
||||
mode: importMode,
|
||||
allowedMimeTypes: [...item.mainRow.allowedMimeTypes],
|
||||
maxSizeMb: item.mainRow.maxSizeMb,
|
||||
maxFiles: item.mainRow.maxFiles,
|
||||
requiresValidityDates: item.mainRow.requiresValidityDates,
|
||||
allowMultiple: (item.mainRow.maxFiles ?? 1) !== 1,
|
||||
isPersonal: false,
|
||||
sortOrder: currentMaxSortOrder + 1,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<div>
|
||||
<Text fw={700} fz="lg">
|
||||
{t('certReq.doc.importPersonal', 'Import from personal documents')}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{localized(licenseType.name)} · {applicationKind}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
size="lg"
|
||||
padding="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
<Text fz="xs">
|
||||
{t(
|
||||
'certReq.doc.importedHelper',
|
||||
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<TextInput
|
||||
placeholder={t('certReq.personal.searchPlaceholder', 'Search by name or key')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="xs"
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={scopeFilter}
|
||||
onChange={(v) => setScopeFilter(v as 'all' | 'scoped')}
|
||||
data={[
|
||||
{ value: 'all', label: t('certReq.personal.filterAny', 'All personal docs') },
|
||||
{
|
||||
value: 'scoped',
|
||||
label: t('certReq.doc.scopeSelected', 'Relevant to licence'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center" gap="sm">
|
||||
<Select
|
||||
label={t('certReq.doc.importMode', 'Requirement mode for imported documents')}
|
||||
size="xs"
|
||||
w={240}
|
||||
data={[
|
||||
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
|
||||
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
|
||||
{
|
||||
value: 'CONDITIONAL',
|
||||
label: t('certReq.doc.modeConditional', 'Required when condition holds'),
|
||||
},
|
||||
]}
|
||||
value={importMode}
|
||||
onChange={(v) => v && setImportMode(v as DocumentRequirement['mode'])}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
<Text fz="xs" c="dimmed" pt="lg">
|
||||
{t('certReq.personal.fileCount', '{{count}} document(s) available', {
|
||||
count: selectableKeys.length,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('certReq.doc.loading', 'Loading personal documents…')}
|
||||
</Text>
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Alert color="red" icon={<IconAlertCircle size={16} />}>
|
||||
{t('certReq.doc.loadFailed', 'Could not load personal documents')}
|
||||
<Button size="xs" variant="subtle" color="red" ml="sm" onClick={() => refetch()}>
|
||||
{t('landing.retry', 'Retry')}
|
||||
</Button>
|
||||
</Alert>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<Card withBorder radius="sm" p="lg" ta="center">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{search
|
||||
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
|
||||
: t('certReq.doc.noPersonalDocs', 'No personal documents configured in Configuration yet.')}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={380} type="auto">
|
||||
<Table highlightOnHover verticalSpacing="xs" fz="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={toggleSelectAll}
|
||||
disabled={selectableKeys.length === 0}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>{t('certReq.personal.columns.document', 'Document')}</Table.Th>
|
||||
<Table.Th>{t('certReq.doc.allowedTypes', 'Format & Limits')}</Table.Th>
|
||||
<Table.Th>{t('certReq.doc.scope', 'Scope')}</Table.Th>
|
||||
<Table.Th ta="right">{t('certReq.personal.columns.actions', 'Actions')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredGroups.map((item) => {
|
||||
const isSelected = selectedKeys.includes(item.key);
|
||||
return (
|
||||
<Table.Tr
|
||||
key={item.key}
|
||||
bg={
|
||||
item.isAlreadyAdded
|
||||
? 'var(--mantine-color-gray-light)'
|
||||
: isSelected
|
||||
? 'var(--mantine-color-blue-light)'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
checked={isSelected || item.isAlreadyAdded}
|
||||
disabled={item.isAlreadyAdded}
|
||||
onChange={() => toggleSelectOne(item.key)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<div>
|
||||
<Group gap={6}>
|
||||
<Text fz="sm" fw={600}>
|
||||
{localized(item.mainRow.name) || item.key}
|
||||
</Text>
|
||||
{item.isAlreadyAdded && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{t('certReq.doc.alreadyAdded', 'Already added')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{item.key}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">
|
||||
{(item.mainRow.allowedMimeTypes ?? []).slice(0, 3).map(shortMime).join(', ')}
|
||||
{(item.mainRow.allowedMimeTypes ?? []).length > 3 && '...'}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{item.mainRow.maxSizeMb} MB ·{' '}
|
||||
{item.mainRow.maxFiles === null
|
||||
? t('certReq.doc.maxFilesUnlimited', 'No limit')
|
||||
: `${item.mainRow.maxFiles} file(s)`}
|
||||
{item.mainRow.requiresValidityDates && ' · Validity'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{item.isGlobal ? (
|
||||
<Badge size="xs" variant="filled" color="gray">
|
||||
{t('certReq.doc.scopeAll', 'All licences')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{t('certReq.doc.scopeSelected', 'Selected')}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Tooltip label={t('certReq.doc.customizeAndAdd', 'Customize & add')}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handleCustomizeRow(item)}
|
||||
>
|
||||
<IconAdjustments size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('certReq.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconDownload size={15} />}
|
||||
disabled={selectedKeys.length === 0}
|
||||
loading={isCreating}
|
||||
onClick={handleBatchImport}
|
||||
>
|
||||
{t('certReq.doc.importSelected', 'Import selected ({{count}})', {
|
||||
count: selectedKeys.length,
|
||||
})}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 && (
|
||||
<Switch
|
||||
label={t('certification.form.isActive', 'Active')}
|
||||
description={t(
|
||||
'certification.form.isActiveHint',
|
||||
'Inactive certifications stay on existing exams but are not offered for new ones.',
|
||||
)}
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||
@@ -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() {
|
||||
</Card>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget ? localized(deleteTarget.name) : '' })}</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "../../components/LicenseTypesTab";
|
||||
import { RankDepartmentTab } from "./RankDepartmentTab";
|
||||
import {
|
||||
useGetOrganizationsQuery,
|
||||
@@ -398,6 +400,9 @@ export function ConfigurationPage() {
|
||||
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
|
||||
{t("location.title")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="licenseTypes" leftSection={<IconLicense size={16} />}>
|
||||
{t("configuration.licenseTypesTab", "Licence Types")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="certifications"
|
||||
leftSection={<IconCertificate size={16} />}
|
||||
@@ -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>
|
||||
|
||||
@@ -104,10 +104,7 @@ const UM_CONFIG: DesignConfig = {
|
||||
const UM_RUNTIME = {
|
||||
basename: "/um",
|
||||
// Keep the embedded IAM module on the same API as the backoffice client.
|
||||
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
|
||||
// fall back to its remote development server, where the local JWT is
|
||||
// rejected and the module redirects to its login page.
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "https://ema-api-dev.triaplc.com/api",
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
|
||||
@@ -618,11 +618,19 @@ export const am: Translations = {
|
||||
descEnPlaceholder: "የእንግሊዝኛ መግለጫ",
|
||||
descAm: "መግለጫ (አማርኛ)",
|
||||
descAmPlaceholder: "የአማርኛ መግለጫ",
|
||||
rankKey: "የSTCW ማዕረግ (ለፈተና መርሐግብር)",
|
||||
rankKeyHint: "ይህ የምስክር ወረቀት የሚፈተነው የCoC/CoP መሰላል አካል ካልሆነ ባዶ ይተዉት።",
|
||||
rankKeyPlaceholder: "ለተወሰነ ማዕረግ አይደለም",
|
||||
isActive: "ንቁ",
|
||||
isActiveHint: "ንቁ ያልሆኑ የምስክር ወረቀቶች በነባር ፈተናዎች ላይ ይቆያሉ፤ ለአዲስ ፈተናዎች ግን አይቀርቡም።",
|
||||
},
|
||||
status: {
|
||||
active: "ንቁ",
|
||||
inactive: "እንቅስቃሴ የሌለ",
|
||||
},
|
||||
validation: {
|
||||
nameRequired: "የእንግሊዝኛ እና የአማርኛ ስሞች ሁለቱም ያስፈልጋሉ",
|
||||
},
|
||||
},
|
||||
|
||||
result: {
|
||||
@@ -874,6 +882,88 @@ export const am: Translations = {
|
||||
|
||||
configuration: {
|
||||
title: "ውቅረት",
|
||||
licenseTypesTab: "የፈቃድ አይነቶች",
|
||||
licenseTypes: {
|
||||
add: "የፈቃድ ዓይነት ያክሉ",
|
||||
edit: "የፈቃድ ዓይነት አስተካክል",
|
||||
key: "ቁልፍ",
|
||||
keyHint:
|
||||
"ቋሚ መለያ፣ ለምሳሌ CUSTOMS_BROKER። ማመልከቻዎች ከጠቀሱት በኋላ ሊቀየር አይችልም።",
|
||||
keyInvalid: "አቢይ ፊደላት፣ አሃዞችና ከስር መስመር፣ ከ3 እስከ 64 ቁምፊዎች",
|
||||
category: "ምድብ",
|
||||
categoryHint:
|
||||
"እነማን ማመልከት እንደሚችሉና የትኞቹ የሹመት ደረጃዎች እርምጃ መውሰድ እንደሚችሉ ይወስናል።",
|
||||
familyKind: "ዓይነት",
|
||||
familyKindHint:
|
||||
"ፈቃድ፣ የምስክር ወረቀት ወይም ሰነድ። አጠቃቀሙን፣ የአመልካች ካታሎጉንና ወረፋው ድርጅት እንደሚያሳይ ይወስናል።",
|
||||
prefix: "የምስክር ወረቀት ቅድመ ቅጥያ",
|
||||
prefixHint:
|
||||
"የእያንዳንዱ ማመልከቻና የምስክር ወረቀት ቁጥር መጀመሪያ፣ ለምሳሌ CB → CB-2026-000042።",
|
||||
prefixInvalid: "ያስፈልጋል፣ ቢበዛ 12 ቁምፊዎች",
|
||||
status: "ሁኔታ",
|
||||
active: "ንቁ",
|
||||
inactive: "እንቅስቃሴ የሌለ",
|
||||
isActive: "ለአመልካቾች ይታያል",
|
||||
isActiveHint: "ቅጹና የሰነድ መስፈርቶቹ እስኪዘጋጁ ድረስ አጥፍተው ይተዉት።",
|
||||
activate: "አንቃ",
|
||||
deactivate: "አሰናክል",
|
||||
activateTitle: "የፈቃድ ዓይነት አንቃ",
|
||||
deactivateTitle: "የፈቃድ ዓይነት አሰናክል",
|
||||
activateText: "{{name}} ለአመልካቾች እንደገና ይቀርባል።",
|
||||
deactivateText:
|
||||
"{{name}} ከአመልካች ካታሎግ ይጠፋል። በሂደት ላይ ያሉ ማመልከቻዎች ሳይነኩ ይቀጥላሉ።",
|
||||
activated: "የፈቃድ ዓይነቱ አሁን ማመልከቻዎችን ይቀበላል",
|
||||
deactivated: "የፈቃድ ዓይነቱ ለአዲስ ማመልከቻዎች ተዘግቷል",
|
||||
noPermission: "የፈቃድ ዓይነት ለመፍጠር ፈቃድ የለዎትም።",
|
||||
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: "ክፍሎች",
|
||||
professions: "ሙያዎች",
|
||||
@@ -909,6 +999,7 @@ export const am: Translations = {
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
queue: {
|
||||
title: "የፈቃድ ማመልከቻዎች",
|
||||
titleByFamily: "{{family}} ማመልከቻዎች",
|
||||
@@ -1526,6 +1617,21 @@ export const am: Translations = {
|
||||
maxFilesUnlimited: "ገደብ የለም",
|
||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||
when: "መቼ",
|
||||
importPersonal: "ከግል ሰነዶች አስመጣ",
|
||||
importPersonalDesc:
|
||||
"ለዚህ የፈቃድ አይነት እንደ መስፈርት ለማከል በውቅር ገጽ ላይ ከተዋቀሩት የግል ሰነዶች ይምረጡ።",
|
||||
importFromPersonal: "ከግል ሰነድ አስመጣ",
|
||||
selectPersonalDoc: "ዝርዝሩን ለመቅዳት የግል ሰነድ ይምረጡ…",
|
||||
importSelected: "የተመረጡትን አስመጣ ({{count}})",
|
||||
importAction: "አስመጣ",
|
||||
customizeAndAdd: "አስተካክለህ ጨምር",
|
||||
alreadyAdded: "ቀደም ሲል ተጨምሯል",
|
||||
noPersonalDocs: "በውቅር ውስጥ የተዘጋጀ የግል ሰነድ እስካሁን የለም።",
|
||||
importedFromPersonal: "ከግል ሰነድ የመጣ: {{name}}",
|
||||
importedHelper:
|
||||
"ከግል ሰነዶች የመጣ። ቁልፉ ከአመልካቹ ሰነዶች ጋር ስለሚዛመድ ፋይሎች በቀጥታ ይገናኛሉ።",
|
||||
importMode: "ለሚመጡ ሰነዶች የመስፈርት ሁነታ",
|
||||
importSuccess: "{{count}} የግል ሰነድ(ዶች) ገብተዋል",
|
||||
},
|
||||
personal: {
|
||||
title: "የግል ሰነዶች",
|
||||
|
||||
@@ -610,6 +610,13 @@ export const en = {
|
||||
description: 'Description',
|
||||
status: 'Status',
|
||||
},
|
||||
status: {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
},
|
||||
validation: {
|
||||
nameRequired: 'Both English and Amharic names are required',
|
||||
},
|
||||
form: {
|
||||
nameEn: 'Name (English)',
|
||||
nameEnPlaceholder: 'Certificate name in English',
|
||||
@@ -619,10 +626,11 @@ export const en = {
|
||||
descEnPlaceholder: 'English description',
|
||||
descAm: 'Description (Amharic)',
|
||||
descAmPlaceholder: 'የአማርኛ መግለጫ',
|
||||
},
|
||||
status: {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
rankKey: 'STCW rank (for exam scheduling)',
|
||||
rankKeyHint: 'Leave blank if this certification is not part of the examined CoC/CoP ladder.',
|
||||
rankKeyPlaceholder: 'Not rank-specific',
|
||||
isActive: 'Active',
|
||||
isActiveHint: 'Inactive certifications stay on existing exams but are not offered for new ones.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -879,6 +887,89 @@ export const en = {
|
||||
|
||||
configuration: {
|
||||
title: 'Configuration',
|
||||
licenseTypesTab: 'Licence Types',
|
||||
licenseTypes: {
|
||||
add: 'Add licence type',
|
||||
edit: 'Edit licence type',
|
||||
key: 'Key',
|
||||
keyHint:
|
||||
'Permanent identifier, e.g. CUSTOMS_BROKER. Cannot be changed once applications reference it.',
|
||||
keyInvalid: 'Upper-case letters, digits and underscores, 3–64 characters',
|
||||
category: 'Category',
|
||||
categoryHint:
|
||||
'Decides which applicants may apply and which officer positions can act on it.',
|
||||
familyKind: 'Kind',
|
||||
familyKindHint:
|
||||
'Licence, certificate or document. Drives the wording, the applicant catalogue and whether the queue shows a company.',
|
||||
prefix: 'Certificate prefix',
|
||||
prefixHint:
|
||||
'Front of every application and certificate number, e.g. CB → CB-2026-000042.',
|
||||
prefixInvalid: 'Required, at most 12 characters',
|
||||
status: 'Status',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
isActive: 'Visible to applicants',
|
||||
isActiveHint:
|
||||
'Leave off until the form and document requirements are configured.',
|
||||
activate: 'Activate',
|
||||
deactivate: 'Deactivate',
|
||||
activateTitle: 'Activate licence type',
|
||||
deactivateTitle: 'Deactivate licence type',
|
||||
activateText: '{{name}} will be offered to applicants again.',
|
||||
deactivateText:
|
||||
'{{name}} will disappear from the applicant catalogue. Applications already in progress continue unaffected.',
|
||||
activated: 'Licence type is now accepting applications',
|
||||
deactivated: 'Licence type is closed to new applications',
|
||||
noPermission: 'You do not have permission to create licence types.',
|
||||
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',
|
||||
professions: 'Professions',
|
||||
@@ -1533,6 +1624,21 @@ export const en = {
|
||||
maxFilesUnlimited: 'No limit',
|
||||
sortOrder: 'Sort order',
|
||||
when: 'when',
|
||||
importPersonal: 'Import from personal documents',
|
||||
importPersonalDesc:
|
||||
'Select documents configured in Personal Documents to add as requirements for this licence type.',
|
||||
importFromPersonal: 'Import from personal document',
|
||||
selectPersonalDoc: 'Select a personal document to copy details…',
|
||||
importSelected: 'Import selected ({{count}})',
|
||||
importAction: 'Import',
|
||||
customizeAndAdd: 'Customize & add',
|
||||
alreadyAdded: 'Already added',
|
||||
noPersonalDocs: 'No personal documents configured in Configuration yet.',
|
||||
importedFromPersonal: 'Imported from personal document: {{name}}',
|
||||
importedHelper:
|
||||
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
|
||||
importMode: 'Requirement mode for imported documents',
|
||||
importSuccess: 'Imported {{count}} personal document(s)',
|
||||
},
|
||||
personal: {
|
||||
title: 'Personal documents',
|
||||
|
||||
@@ -14,8 +14,9 @@ export default defineConfig({
|
||||
host: 'localhost',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://ema-api-dev.triaplc.com',
|
||||
target: process.env.VITE_API_PROXY_TARGET || 'https://ema-api-dev.triaplc.com',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,9 +13,10 @@ export default defineConfig({
|
||||
port: 4200,
|
||||
host: "localhost",
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://ema-api-dev.triaplc.com',
|
||||
"/api": {
|
||||
target: process.env.VITE_API_PROXY_TARGET || "https://ema-api-dev.triaplc.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user