Merge branch 'createNewLicenseType' of https://github.com/Tria-plc/emaui into createNewLicenseType

This commit is contained in:
Estifo77
2026-09-02 14:18:35 +03:00
12 changed files with 784 additions and 43 deletions

View File

@@ -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>

View File

@@ -0,0 +1,457 @@
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,
type CreateLicenseTypeInput,
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 [updateStatus, { isLoading: isToggling }] = useUpdateLicenseStatusMutation();
const [showForm, setShowForm] = useState(false);
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('licenseTypeConfig.family.LOGISTICS_LICENSE', 'Logistics licence') },
{ value: 'CERTIFICATE', label: t('licenseTypeConfig.family.CERTIFICATE', 'Seafarer certificate') },
{ value: 'DOCUMENT', label: t('licenseTypeConfig.family.DOCUMENT', 'Identity / statutory document') },
];
const serviceOptions: { value: ServiceKind; label: string }[] = [
{ value: 'LICENSE', label: t('licenseTypeConfig.service.LICENSE', 'Licence') },
{ value: 'REGISTRATION', label: t('licenseTypeConfig.service.REGISTRATION', 'Registration') },
];
const workflowOptions: { value: WorkflowProfile; label: string }[] = [
{ value: 'STANDARD', label: t('licenseTypeConfig.workflow.STANDARD', 'Standard (review → evaluation → inspection → approval)') },
{ value: 'REGISTRATION', label: t('licenseTypeConfig.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('licenseTypeConfig.validation.keyRequired', 'Key is required');
if (key.length > 64) return t('licenseTypeConfig.validation.keyTooLong', 'Key must be at most 64 characters');
if (!KEY_PATTERN.test(key)) {
return t('licenseTypeConfig.validation.keyFormat', 'Use letters, digits and underscores, e.g. PORT_AGENT');
}
if (types.some((lt) => lt.key === key)) {
return t('licenseTypeConfig.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('licenseTypeConfig.validation.prefixRequired', 'Certificate prefix is required');
if (prefix.length > 12) return t('licenseTypeConfig.validation.prefixTooLong', 'Prefix must be at most 12 characters');
return null;
},
feeCurrency: (v) => (v.trim().length > 8 ? t('licenseTypeConfig.validation.currencyTooLong', 'Use a short currency code') : null),
validityMonths: (v) =>
v >= 6 && v <= 240 ? null : t('licenseTypeConfig.validation.validityRange', 'Validity must be between 6 and 240 months'),
},
});
const closeForm = () => {
form.reset();
setShowForm(false);
};
const submit = form.onSubmit(async (values) => {
const body: CreateLicenseTypeInput = {
key: values.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,
// The last row by default; the seeded order is EMA's and stays put.
sortOrder: types.length,
};
if (values.descEn.trim() || values.descAm.trim()) {
body.description = { en: values.descEn.trim(), am: values.descAm.trim() };
}
if (values.category) body.category = values.category;
if (values.feeNewApplication !== '') body.feeNewApplication = values.feeNewApplication;
try {
await createType(body).unwrap();
notify.success(t('licenseTypeConfig.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('licenseTypeConfig.activated', 'Licence type is now accepting applications')
: t('licenseTypeConfig.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('licenseTypeConfig.columns.category', 'Category'), cell: ({ row }) => categoryLabel(row.original.category) },
{
header: t('licenseTypeConfig.columns.family', 'Family'),
cell: ({ row }) => familyOptions.find((f) => f.value === row.original.familyKind)?.label ?? row.original.familyKind,
},
{ header: t('licenseTypeConfig.columns.prefix', 'Prefix'), cell: ({ row }) => row.original.certificatePrefix },
{
header: t('licenseTypeConfig.columns.status', 'Status'),
size: 110,
cell: ({ row }) => (
<Badge color={row.original.isActive ? 'green' : 'gray'} variant="light">
{row.original.isActive ? t('licenseTypeConfig.active', 'Active') : t('licenseTypeConfig.inactive', 'Inactive')}
</Badge>
),
},
{
header: '',
label: t('licenseTypeConfig.columns.actions', 'Actions'),
size: 140,
cell: ({ row }) => (
<Switch
size="sm"
checked={row.original.isActive}
disabled={!canToggle || isToggling}
onChange={() => setPendingToggle(row.original)}
label={row.original.isActive ? t('licenseTypeConfig.deactivate', 'Deactivate') : t('licenseTypeConfig.activate', 'Activate')}
aria-label={t('licenseTypeConfig.toggleAria', 'Toggle whether this licence type accepts applications')}
/>
),
},
];
const page = paginate(types);
return (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
<Text size="sm">
{t(
'licenseTypeConfig.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('licenseTypeConfig.goToRequirements', 'Certificate requirements')}
</Anchor>
{' · '}
<Anchor component={Link} to="/payment-config" size="sm">
{t('licenseTypeConfig.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={() => setShowForm(true)}>
{t('licenseTypeConfig.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('licenseTypeConfig.empty', 'No licence types yet')}
/>
</Card>
<Modal opened={showForm} onClose={closeForm} title={t('licenseTypeConfig.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('licenseTypeConfig.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())}
/>
<TextInput
label={t('licenseTypeConfig.prefix', 'Certificate number prefix')}
description={t('licenseTypeConfig.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('licenseTypeConfig.columns.category', 'Category')}
description={t('licenseTypeConfig.categoryHint', 'The group the applicant browses by.')}
data={categoryOptions}
clearable
searchable
{...form.getInputProps('category')}
/>
<Select
label={t('licenseTypeConfig.columns.family', 'Family')}
description={t('licenseTypeConfig.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('licenseTypeConfig.fee', 'New application fee')}
description={t('licenseTypeConfig.feeHint', 'Leave blank for no charge.')}
min={0}
decimalScale={2}
thousandSeparator=","
{...form.getInputProps('feeNewApplication')}
/>
<TextInput label={t('licenseTypeConfig.currency', 'Currency')} maxLength={8} {...form.getInputProps('feeCurrency')} />
<NumberInput
label={t('licenseTypeConfig.validity', 'Validity (months)')}
min={6}
max={240}
allowDecimal={false}
required
{...form.getInputProps('validityMonths')}
/>
</SimpleGrid>
<Group gap="lg">
<Checkbox
label={t('licenseTypeConfig.issuesCertificate', 'Issues a certificate')}
{...form.getInputProps('issuesCertificate', { type: 'checkbox' })}
/>
<Checkbox
label={t('licenseTypeConfig.renewalEnabled', 'Renewable')}
{...form.getInputProps('renewalEnabled', { type: 'checkbox' })}
/>
<Checkbox
label={t('licenseTypeConfig.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}>
{t('configuration.create')}
</Button>
</ModalFooter>
</Stack>
</form>
</Modal>
<Modal
opened={pendingToggle !== null}
onClose={() => setPendingToggle(null)}
title={
pendingToggle?.isActive
? t('licenseTypeConfig.deactivateTitle', 'Deactivate licence type')
: t('licenseTypeConfig.activateTitle', 'Activate licence type')
}
size="sm"
>
<Text size="sm" mb="md">
{pendingToggle?.isActive
? t('licenseTypeConfig.deactivateText', {
name: pendingToggle ? localized(pendingToggle.name) : '',
defaultValue:
'{{name}} will disappear from the applicant catalogue. Applications already in progress continue unaffected.',
})
: t('licenseTypeConfig.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('licenseTypeConfig.deactivate', 'Deactivate')
: t('licenseTypeConfig.activate', 'Activate')}
</Button>
</ModalFooter>
</Modal>
</Stack>
);
}

View File

@@ -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,
@@ -400,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} />}
@@ -428,6 +431,10 @@ export function ConfigurationPage() {
<LocationPage />
</Tabs.Panel>
<Tabs.Panel value="licenseTypes" pt="md">
<LicenseTypesTab />
</Tabs.Panel>
<Tabs.Panel value="certifications" pt="md">
<CertificationPage />
</Tabs.Panel>

View File

@@ -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 = {

View File

@@ -620,11 +620,19 @@ export const am: Translations = {
descEnPlaceholder: "የእንግሊዝኛ መግለጫ",
descAm: "መግለጫ (አማርኛ)",
descAmPlaceholder: "የአማርኛ መግለጫ",
rankKey: "የSTCW ማዕረግ (ለፈተና መርሐግብር)",
rankKeyHint: "ይህ የምስክር ወረቀት የሚፈተነው የCoC/CoP መሰላል አካል ካልሆነ ባዶ ይተዉት።",
rankKeyPlaceholder: "ለተወሰነ ማዕረግ አይደለም",
isActive: "ንቁ",
isActiveHint: "ንቁ ያልሆኑ የምስክር ወረቀቶች በነባር ፈተናዎች ላይ ይቆያሉ፤ ለአዲስ ፈተናዎች ግን አይቀርቡም።",
},
status: {
active: "ንቁ",
inactive: "እንቅስቃሴ የሌለ",
},
validation: {
nameRequired: "የእንግሊዝኛ እና የአማርኛ ስሞች ሁለቱም ያስፈልጋሉ",
},
},
result: {
@@ -876,6 +884,7 @@ export const am: Translations = {
configuration: {
title: "ውቅረት",
licenseTypesTab: "የፈቃድ አይነቶች",
personalDocumentsTab: "የግል ሰነዶች",
licenseTypesTab: "የፈቃድ ዓይነቶች",
departments: "ክፍሎች",
@@ -912,6 +921,70 @@ export const am: Translations = {
},
},
licenseTypeConfig: {
add: "የፈቃድ አይነት ያክሉ",
empty: "እስካሁን ምንም የፈቃድ አይነቶች የሉም",
notice:
"አዲስ አይነት ባዶ ሆኖ ይጀምራል። ከፈጠሩ በኋላ ቅጹንና የሰነድ መስፈርቶቹን በምስክር ወረቀት መስፈርቶች ስር፣ ክፍያዎቹን ደግሞ በክፍያ ውቅረት ስር ያዘጋጁ።",
goToRequirements: "የምስክር ወረቀት መስፈርቶች",
goToFees: "የክፍያ ውቅረት",
created: "የፈቃድ አይነት ተፈጥሯል። በመቀጠል ቅጹን፣ ሰነዶቹንና ክፍያዎቹን ያዘጋጁ።",
activated: "የፈቃድ አይነቱ አሁን ማመልከቻዎችን ይቀበላል",
deactivated: "የፈቃድ አይነቱ ለአዲስ ማመልከቻዎች ተዘግቷል",
active: "ንቁ",
inactive: "እንቅስቃሴ የሌለ",
activate: "አንቃ",
deactivate: "አሰናክል",
activateTitle: "የፈቃድ አይነት አንቃ",
deactivateTitle: "የፈቃድ አይነት አሰናክል",
activateText: "{{name}} ለአመልካቾች እንደገና ይቀርባል።",
deactivateText:
"{{name}} ከአመልካች ካታሎግ ይጠፋል። በሂደት ላይ ያሉ ማመልከቻዎች ሳይነኩ ይቀጥላሉ።",
toggleAria: "ይህ የፈቃድ አይነት ማመልከቻ መቀበል አለመቀበሉን ይቀያይሩ",
keyHint: "ቋሚ መለያ፣ በትልቅ ፊደል እና ከስር መስመር ጋር። ማመልከቻዎች ከተፈጠሩ በኋላ ሊቀየር አይችልም።",
prefix: "የምስክር ወረቀት ቁጥር ቅድመ ቅጥያ",
prefixHint: "ለምሳሌ FF → FF-2026-000123",
categoryHint: "አመልካቹ የሚያስስበት ቡድን።",
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 ወራት መካከል መሆን አለበት",
},
},
queue: {
title: "የፈቃድ ማመልከቻዎች",
titleByFamily: "{{family}} ማመልከቻዎች",

View File

@@ -612,6 +612,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',
@@ -621,10 +628,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.',
},
},
@@ -881,6 +889,7 @@ export const en = {
configuration: {
title: 'Configuration',
licenseTypesTab: 'Licence Types',
personalDocumentsTab: 'Personal Documents',
licenseTypesTab: 'Licence types',
departments: 'Departments',
@@ -917,6 +926,70 @@ export const en = {
},
},
licenseTypeConfig: {
add: 'Add licence type',
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.',
activated: 'Licence type is now accepting applications',
deactivated: 'Licence type is closed to new applications',
active: 'Active',
inactive: 'Inactive',
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.',
toggleAria: 'Toggle whether this licence type accepts applications',
keyHint: 'Stable identifier, upper snake case. Cannot change once applications exist.',
prefix: 'Certificate number prefix',
prefixHint: 'e.g. FF → FF-2026-000123',
categoryHint: 'The group the applicant browses by.',
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',
},
},
queue: {
title: 'Licence applications',
// {{family}} is "Certificate"/"Document"/"Licence" — used only on a

View File

@@ -12,16 +12,14 @@ export default defineConfig({
server: {
port: 4201,
host: 'localhost',
proxy: {
'/api': {
target: process.env.VITE_API_PROXY_TARGET || 'https://ema-api-dev.triaplc.com',
changeOrigin: true,
secure: false,
},
},
},
// server: {
// port: 4201,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 4201, host: 'localhost' },
plugins: [react(), nxViteTsPaths()],
resolve: {

View File

@@ -9,16 +9,17 @@ export default defineConfig({
// built-in default.
envDir: "../../",
cacheDir: "../../node_modules/.vite/apps/portal",
server: { port: 4200, host: "localhost" },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
server: {
port: 4200,
host: "localhost",
proxy: {
"/api": {
target: process.env.VITE_API_PROXY_TARGET || "https://ema-api-dev.triaplc.com",
changeOrigin: true,
secure: false,
},
},
},
preview: { port: 4200, host: "localhost" },
plugins: [react(), nxViteTsPaths()],
resolve: {