Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-21 06:58:57 +00:00
53 changed files with 2950 additions and 1258 deletions

View File

@@ -0,0 +1,220 @@
import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
import { useLocalized } from '@ema-platform/api';
import type { ConditionTarget } from '../config/schema-paths';
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
function operatorOf(condition: ConditionValue | undefined): Operator | null {
if (!condition) return null;
if (condition.isSet !== undefined) return 'isSet';
if (condition.equals !== undefined) return 'equals';
if (condition.notEquals !== undefined) return 'notEquals';
if (condition.in !== undefined) return 'in';
return null;
}
/** Best-effort type for a raw stored value, so re-editing an existing
* condition renders a number input for a numeric value rather than text. */
function coerce(raw: string, targetType: string | undefined): string | number | boolean {
if (targetType === 'BOOLEAN') return raw === 'true';
if (targetType === 'NUMBER' || targetType === 'MONEY') {
const n = Number(raw);
return Number.isFinite(n) && raw.trim() !== '' ? n : raw;
}
return raw;
}
/**
* Authors one `FieldCondition` (`showWhen` on a section/field, or
* `conditionExpression` on a document requirement).
*
* The field-path box autocompletes from every field already defined in this
* licence type's schema (`targets`); when the chosen path resolves to a
* SELECT field, the value picker switches to that field's own options
* instead of free text — the condition can only ever reference an answer
* that could actually be chosen.
*/
export function ConditionBuilder({
value,
onChange,
targets,
palette,
allowClear = true,
}: {
value: ConditionValue | null;
onChange: (value: ConditionValue | null) => void;
targets: ConditionTarget[];
palette: FormSchemaPalette | undefined;
/** Hide the "no condition" toggle — used where a condition is mandatory (CONDITIONAL document mode). */
allowClear?: boolean;
}) {
const { t } = useTranslation();
const localized = useLocalized();
const active = value !== null;
const operator = operatorOf(value ?? undefined) ?? 'equals';
const target = targets.find((c) => c.path === value?.field);
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
function setField(field: string) {
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
}
function setOperator(next: Operator) {
if (!value?.field) return;
const base: ConditionValue = { field: value.field };
if (next === 'isSet') base.isSet = true;
else if (next === 'in') base.in = [];
else if (next === 'notEquals') base.notEquals = '';
else base.equals = '';
onChange(base);
}
function setValueRaw(raw: string) {
if (!value?.field) return;
const coerced = coerce(raw, target?.field.type);
if (operator === 'equals') onChange({ field: value.field, equals: coerced });
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
}
function setInValues(raws: string[]) {
if (!value?.field) return;
onChange({
field: value.field,
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
});
}
return (
<Stack gap="xs">
{allowClear && (
<Switch
label={t('certReq.condition.enable', 'Only apply when a condition holds')}
checked={active}
onChange={(e) => onChange(e.currentTarget.checked ? { field: '', equals: '' } : null)}
/>
)}
{active && (
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
<Autocomplete
label={t('certReq.condition.field', 'Field path')}
placeholder="certificate.rank"
description={t(
'certReq.condition.fieldHelp',
'Dot path into the form, e.g. sectionKey.fieldKey',
)}
data={targets.map((c) => c.path)}
value={value?.field ?? ''}
onChange={setField}
/>
<Group grow align="flex-start">
<Select
label={t('certReq.condition.operator', 'Operator')}
data={operators.map((op) => ({ value: op, label: op }))}
value={operator}
onChange={(v) => v && setOperator(v as Operator)}
allowDeselect={false}
/>
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
<Select
label={t('certReq.condition.value', 'Value')}
data={(target.field.options ?? []).map((o) => ({
value: o.value,
label: localized(o.label) || o.value,
}))}
value={String(value?.equals ?? value?.notEquals ?? '')}
onChange={(v) => v !== null && setValueRaw(v)}
/>
)}
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
target?.field.type === 'BOOLEAN' ? (
<Checkbox
mt="xl"
label={t('certReq.condition.value', 'Value')}
checked={Boolean(value?.equals ?? value?.notEquals ?? false)}
onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
/>
) : (
<TextInput
label={t('certReq.condition.value', 'Value')}
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'}
value={String(value?.equals ?? value?.notEquals ?? '')}
onChange={(e) => setValueRaw(e.currentTarget.value)}
/>
)
)}
{operator === 'in' && target?.field.type === 'SELECT' && (
<Select
label={t('certReq.condition.values', 'Any of')}
data={(target.field.options ?? []).map((o) => ({
value: o.value,
label: localized(o.label) || o.value,
}))}
multiple={undefined}
value={null}
onChange={(v) => {
if (!v) return;
const current = (value?.in ?? []) as string[];
if (!current.includes(v)) setInValues([...current, v]);
}}
/>
)}
{operator === 'in' && target?.field.type !== 'SELECT' && (
<TextInput
label={t('certReq.condition.values', 'Any of (comma-separated)')}
value={(value?.in ?? []).join(', ')}
onChange={(e) =>
setInValues(
e.currentTarget.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
)
}
/>
)}
</Group>
{operator === 'in' && (value?.in?.length ?? 0) > 0 && (
<Group gap={4}>
{(value?.in ?? []).map((v, i) => (
<Text
key={`${v}-${i}`}
fz="xs"
px={6}
py={2}
bg="var(--mantine-color-gray-1)"
style={{ borderRadius: 4, cursor: 'pointer' }}
onClick={() => setInValues((value?.in ?? []).filter((_, idx) => idx !== i).map(String))}
title={t('certReq.condition.removeValue', 'Click to remove')}
>
{String(v)} ×
</Text>
))}
</Group>
)}
{!target && value?.field && (
<Text fz="xs" c="dimmed">
{t(
'certReq.condition.unknownField',
'This path is not a field in the current schema yet — it will still be saved as typed.',
)}
</Text>
)}
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react';
import {
Button,
Checkbox,
Divider,
Drawer,
MultiSelect,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import type { ApplicationKind, DocumentRequirement, FormSchemaPalette } from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
const MIME_OPTIONS = [
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image/jpeg', label: 'JPEG' },
{ value: 'image/png', label: 'PNG' },
];
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
function emptyDraft(applicationKind: ApplicationKind): DraftRequirement {
return {
key: '',
name: { en: '', am: '' },
applicationKind,
mode: 'ALWAYS',
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
maxSizeMb: 5,
requiresValidityDates: false,
allowMultiple: false,
sortOrder: 0,
};
}
/** Adds/edits one document requirement slot for a licence type + application kind. */
export function DocumentRequirementEditorDrawer({
opened,
onClose,
requirement,
defaultApplicationKind,
onSave,
palette,
conditionTargets,
saving,
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new requirement. */
requirement: DocumentRequirement | null;
defaultApplicationKind: ApplicationKind;
onSave: (draft: DraftRequirement) => void;
palette: FormSchemaPalette | undefined;
conditionTargets: ConditionTarget[];
saving: boolean;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<DraftRequirement>(emptyDraft(defaultApplicationKind));
const [keyError, setKeyError] = useState<string | null>(null);
const isNew = !requirement;
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,
sortOrder: requirement.sortOrder,
}
: emptyDraft(defaultApplicationKind),
);
setKeyError(null);
}
}, [opened, requirement, defaultApplicationKind]);
function save() {
if (!draft.key.trim()) {
setKeyError(t('certReq.doc.keyRequired', 'Key is required'));
return;
}
if (!draft.name.en?.trim()) return;
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) {
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
return;
}
onSave({
...draft,
key: draft.key.trim(),
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
});
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={<Text fw={700}>{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}</Text>}
>
<Stack gap="md">
<TextInput
label={t('certReq.doc.key', 'Key')}
placeholder="bank_letter"
required
value={draft.key}
error={keyError}
disabled={!isNew}
description={isNew ? t('certReq.doc.keyHelp', 'Stable slug identifying this document slot') : t('certReq.doc.keyLocked', 'Key cannot change once created')}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
/>
<BilingualInput
label={t('certReq.doc.name', 'Name')}
required
value={{ en: draft.name.en ?? '', am: draft.name.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
/>
<BilingualInput
label={t('certReq.doc.description', 'Description')}
value={{ en: draft.description?.en ?? '', am: draft.description?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
/>
<Select
label={t('certReq.doc.applicationKind', 'Application kind')}
data={[
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
]}
value={draft.applicationKind}
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
allowDeselect={false}
disabled={!isNew}
description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined}
/>
<Select
label={t('certReq.doc.mode', 'Mode')}
data={[
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
]}
value={draft.mode}
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
allowDeselect={false}
/>
{draft.mode === 'CONDITIONAL' && (
<>
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
<ConditionBuilder
value={(draft.conditionExpression ?? null) as ConditionValue | null}
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
targets={conditionTargets}
palette={palette}
allowClear={false}
/>
</>
)}
<MultiSelect
label={t('certReq.doc.allowedTypes', 'Allowed file types')}
data={MIME_OPTIONS}
value={draft.allowedMimeTypes}
onChange={(v) => setDraft((d) => ({ ...d, allowedMimeTypes: v }))}
/>
<NumberInput
label={t('certReq.doc.maxSize', 'Max file size (MB)')}
min={1}
value={draft.maxSizeMb}
onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))}
/>
<Checkbox
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
checked={draft.requiresValidityDates}
onChange={(e) => setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))}
/>
<Checkbox
label={t('certReq.doc.allowMultiple', 'Allow multiple uploads')}
checked={draft.allowMultiple}
onChange={(e) => setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))}
/>
<NumberInput
label={t('certReq.doc.sortOrder', 'Sort order')}
value={draft.sortOrder}
onChange={(v) => setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : d.sortOrder }))}
/>
<ModalFooter>
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="teal" loading={saving} onClick={save}>
{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.saveChanges', 'Save changes')}
</Button>
</ModalFooter>
</Stack>
</Drawer>
);
}

View File

@@ -0,0 +1,203 @@
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 { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
import {
extractErrorMessage,
useCreateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetDocumentRequirementsQuery,
useGetFormSchemaPaletteQuery,
useLocalized,
useUpdateDocumentRequirementMutation,
type ApplicationKind,
type DocumentRequirement,
type LicenseType,
} from '@ema-platform/api';
import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
const MODE_COLOR: Record<DocumentRequirement['mode'], string> = {
ALWAYS: 'blue',
CONDITIONAL: 'violet',
OPTIONAL: 'gray',
};
/**
* Document upload requirements for one licence type, grouped by application
* kind (a new-application slot and its renewal counterpart are different
* rows even when they share a key). Every edit is a real CRUD call the
* moment the admin confirms it — there is no separate "save all" step here,
* unlike the form schema tab's whole-document replace.
*/
export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseType }) {
const { t } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data, isLoading, isError, error, refetch } = useGetDocumentRequirementsQuery();
const { data: palette } = useGetFormSchemaPaletteQuery();
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
const requirements = useMemo(
() => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id),
[data, licenseType.id],
);
const conditionTargets = collectConditionTargets(licenseType.formSchema.sections);
async function handleSave(draft: Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>) {
const ok = await run(
() =>
editorState?.requirement
? updateRequirement({ id: editorState.requirement.id, ...draft }).unwrap()
: createRequirement({ ...draft, licenseTypeId: licenseType.id }).unwrap(),
editorState?.requirement
? t('certReq.doc.updated', 'Document requirement updated')
: t('certReq.doc.created', 'Document requirement added'),
);
if (ok) setEditorState(null);
}
async function confirmDelete() {
if (!deleteTarget) return;
const ok = await run(
() => deleteRequirement(deleteTarget.id).unwrap(),
t('certReq.doc.deleted', 'Document requirement removed'),
);
if (ok) setDeleteTarget(null);
}
if (isLoading) return <PageLoader label={t('certReq.doc.loading', 'Loading document requirements…')} height={300} />;
if (isError) {
return (
<ErrorState
title={t('certReq.doc.loadFailed', 'Could not load document requirements')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
icon={IconAlertCircle}
/>
);
}
return (
<Stack gap="lg">
<Text fz="sm" c="dimmed">
{t(
'certReq.doc.subtitle',
'What an applicant must upload for this licence type, split by new application and renewal.',
)}
</Text>
{KINDS.map((kind) => {
const rows = requirements
.filter((r) => r.applicationKind === kind)
.sort((a, b) => a.sortOrder - b.sortOrder);
return (
<Card key={kind} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Title order={5}>
{kind === 'NEW' ? t('certReq.doc.kindNew', 'New application') : t('certReq.doc.kindRenewal', 'Renewal')}
</Title>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => setEditorState({ kind, requirement: null })}
>
{t('certReq.doc.add', 'Add document requirement')}
</Button>
</Group>
{rows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
{t('certReq.doc.emptyKind', 'No document requirements for this application kind yet.')}
</Text>
) : (
<Stack gap="xs">
{rows.map((req) => (
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Group gap={6}>
<Text fz="sm" fw={600} truncate>{localized(req.name) || req.key}</Text>
<Badge size="xs" color={MODE_COLOR[req.mode]} variant="light">{req.mode}</Badge>
{req.allowMultiple && <Badge size="xs" variant="outline">{t('certReq.doc.multiple', 'multiple')}</Badge>}
</Group>
<Text fz="xs" c="dimmed" truncate>
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
</Text>
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
<Text fz="xs" c="violet">
{t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '}
{req.conditionExpression.equals !== undefined && `= ${req.conditionExpression.equals}`}
{req.conditionExpression.notEquals !== undefined && `${req.conditionExpression.notEquals}`}
{req.conditionExpression.in !== undefined && `∈ [${req.conditionExpression.in.join(', ')}]`}
{req.conditionExpression.isSet !== undefined && (req.conditionExpression.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'))}
</Text>
)}
</div>
<Group gap={4} wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => setEditorState({ kind, requirement: req })}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(req)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
))}
</Stack>
)}
</Card>
);
})}
{requirements.length === 0 && (
<EmptyState
title={t('certReq.doc.empty', 'No document requirements configured')}
description={t('certReq.doc.emptyBody', 'Add the documents an applicant must upload for this licence type.')}
/>
)}
<DocumentRequirementEditorDrawer
opened={editorState !== null}
onClose={() => setEditorState(null)}
requirement={editorState?.requirement ?? null}
defaultApplicationKind={editorState?.kind ?? 'NEW'}
onSave={handleSave}
palette={palette}
conditionTargets={conditionTargets}
saving={creating || updating}
/>
<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">
{t('certReq.doc.deleteWarning', 'Applicants already relying on this slot will no longer see it. This cannot be undone.')}
</Alert>
<Text fz="sm">
{t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', {
name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.key : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="red" onClick={confirmDelete}>{t('certReq.delete', 'Delete')}</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,224 @@
import { useEffect, useState } from 'react';
import {
Button,
Checkbox,
Divider,
Drawer,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import type { FormFieldConfig, FormSchemaPalette } from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
function emptyField(): FormFieldConfig {
return { key: '', label: { en: '', am: '' }, type: 'TEXT' };
}
/** Adds/edits one field within a section. Options only show for SELECT. */
export function FieldEditorDrawer({
opened,
onClose,
field,
onSave,
palette,
conditionTargets,
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new field. */
field: FormFieldConfig | null;
onSave: (field: FormFieldConfig) => void;
palette: FormSchemaPalette | undefined;
conditionTargets: ConditionTarget[];
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<FormFieldConfig>(emptyField());
const [keyError, setKeyError] = useState<string | null>(null);
useEffect(() => {
if (opened) {
setDraft(field ? { ...field, label: { ...field.label } } : emptyField());
setKeyError(null);
}
}, [opened, field]);
const typeInfo = palette?.fieldTypes.find((f) => f.type === draft.type);
const isNew = !field;
function save() {
if (!draft.key.trim() || !KEY_PATTERN.test(draft.key.trim())) {
setKeyError(t('certReq.field.keyInvalid', 'Key must start with a letter and contain only letters, numbers, underscores'));
return;
}
if (!draft.label.en?.trim()) {
setKeyError(null);
return;
}
onSave({
...draft,
key: draft.key.trim(),
options: typeInfo?.supportsOptions ? draft.options : undefined,
min: typeInfo?.supportsRange ? draft.min : undefined,
max: typeInfo?.supportsRange ? draft.max : undefined,
maxLength: typeInfo?.supportsMaxLength ? draft.maxLength : undefined,
});
}
function addOption() {
setDraft((d) => ({
...d,
options: [...(d.options ?? []), { value: '', label: { en: '', am: '' } }],
}));
}
function updateOption(index: number, patch: Partial<{ value: string; label: { en: string; am: string } }>) {
setDraft((d) => ({
...d,
options: (d.options ?? []).map((o, i) => (i === index ? { ...o, ...patch } : o)),
}));
}
function removeOption(index: number) {
setDraft((d) => ({ ...d, options: (d.options ?? []).filter((_, i) => i !== index) }));
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={<Text fw={700}>{isNew ? t('certReq.field.add', 'Add field') : t('certReq.field.edit', 'Edit field')}</Text>}
>
<Stack gap="md">
<TextInput
label={t('certReq.field.key', 'Field key')}
placeholder="rank"
required
value={draft.key}
error={keyError}
disabled={!isNew}
description={
isNew
? t('certReq.field.keyHelp', 'Letters, numbers and underscores only — becomes the form data key')
: t('certReq.field.keyLocked', 'Key cannot change once created')
}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
/>
<BilingualInput
label={t('certReq.field.label', 'Label')}
required
value={{ en: draft.label.en ?? '', am: draft.label.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, label: v }))}
/>
<Select
label={t('certReq.field.type', 'Field type')}
data={(palette?.fieldTypes ?? []).map((f) => ({ value: f.type, label: f.type }))}
value={draft.type}
onChange={(v) => v && setDraft((d) => ({ ...d, type: v as FormFieldConfig['type'] }))}
allowDeselect={false}
/>
<Checkbox
label={t('certReq.field.required', 'Required')}
checked={Boolean(draft.required)}
onChange={(e) => setDraft((d) => ({ ...d, required: e.currentTarget.checked }))}
/>
<BilingualInput
label={t('certReq.field.placeholder', 'Placeholder')}
value={{ en: draft.placeholder?.en ?? '', am: draft.placeholder?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, placeholder: v }))}
/>
<BilingualInput
label={t('certReq.field.helpText', 'Help text')}
value={{ en: draft.helpText?.en ?? '', am: draft.helpText?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, helpText: v }))}
/>
{typeInfo?.supportsRange && (
<Group grow>
<NumberInput
label={t('certReq.field.min', 'Minimum')}
value={draft.min ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, min: typeof v === 'number' ? v : undefined }))}
/>
<NumberInput
label={t('certReq.field.max', 'Maximum')}
value={draft.max ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, max: typeof v === 'number' ? v : undefined }))}
/>
</Group>
)}
{typeInfo?.supportsMaxLength && (
<NumberInput
label={t('certReq.field.maxLength', 'Max length')}
value={draft.maxLength ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, maxLength: typeof v === 'number' ? v : undefined }))}
/>
)}
{typeInfo?.supportsOptions && (
<Stack gap="xs">
<Group justify="space-between">
<Text fz="sm" fw={600}>{t('certReq.field.options', 'Options')}</Text>
<Button size="xs" variant="light" leftSection={<IconPlus size={13} />} onClick={addOption}>
{t('certReq.field.addOption', 'Add option')}
</Button>
</Group>
{(draft.options ?? []).map((o, i) => (
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
<TextInput
size="xs"
label={i === 0 ? t('certReq.field.optionValue', 'Value') : undefined}
value={o.value}
onChange={(e) => updateOption(i, { value: e.currentTarget.value })}
style={{ flex: 1 }}
/>
<BilingualInput
size="xs"
label={i === 0 ? t('certReq.field.optionLabel', 'Label') : undefined}
value={{ en: o.label.en ?? '', am: o.label.am ?? '' }}
onChange={(v) => updateOption(i, { label: v })}
style={{ flex: 2 }}
/>
<Button size="xs" color="red" variant="subtle" px={6} onClick={() => removeOption(i)}>
<IconTrash size={14} />
</Button>
</Group>
))}
</Stack>
)}
<Divider label={t('certReq.condition.title', 'Visibility condition')} labelPosition="left" />
<ConditionBuilder
value={(draft.showWhen ?? null) as ConditionValue | null}
onChange={(v) => setDraft((d) => ({ ...d, showWhen: v ?? undefined }))}
targets={conditionTargets}
palette={palette}
/>
<ModalFooter>
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="teal" onClick={save}>
{isNew ? t('certReq.field.add', 'Add field') : t('certReq.saveChanges', 'Save changes')}
</Button>
</ModalFooter>
</Stack>
</Drawer>
);
}

View File

@@ -0,0 +1,334 @@
import { useEffect, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Group,
Modal,
Stack,
Text,
} from '@mantine/core';
import {
IconAlertTriangle,
IconChevronDown,
IconChevronUp,
IconEdit,
IconGripVertical,
IconPlus,
IconTrash,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { EmptyState, ModalFooter, notify } from '@ema-platform/ui';
import {
extractErrorMessage,
useGetFormSchemaPaletteQuery,
useLocalized,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
type FormFieldConfig,
type FormSectionConfig,
type LicenseType,
type SchemaIssue,
} from '@ema-platform/api';
import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { FieldEditorDrawer } from './FieldEditorDrawer';
import { SectionEditorDrawer } from './SectionEditorDrawer';
function moveItem<T>(list: T[], index: number, direction: -1 | 1): T[] {
const target = index + direction;
if (target < 0 || target >= list.length) return list;
const next = [...list];
[next[index], next[target]] = [next[target], next[index]];
return next.map((item, i) => ({ ...item, sortOrder: i } as T));
}
/**
* Sections/fields editor for one licence type's `formSchema`.
*
* Edits build up a local draft; nothing is sent until "Save schema" — the
* server replaces the whole `formSchema` in one `PUT`, so partial saves would
* not match what the API accepts anyway. "Check for errors" dry-runs the same
* lint the save uses, so an author can fix problems before committing.
*/
export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
const { t } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data: palette } = useGetFormSchemaPaletteQuery();
const [saveSchema, { isLoading: saving }] = useUpdateFormSchemaMutation();
const [validateSchema, { isLoading: validating }] = useValidateFormSchemaMutation();
const [sections, setSections] = useState<FormSectionConfig[]>(licenseType.formSchema.sections);
const [issues, setIssues] = useState<SchemaIssue[] | null>(null);
const [dirty, setDirty] = useState(false);
// A newly selected licence type replaces the draft outright. Deliberately
// keyed on the id alone: a background refetch of the *same* type (e.g. the
// list tag invalidation right after this tab's own save) must not clobber
// whatever the admin is mid-editing.
useEffect(() => {
setSections(licenseType.formSchema.sections);
setIssues(null);
setDirty(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [licenseType.id]);
const [sectionDrawer, setSectionDrawer] = useState<{ section: FormSectionConfig | null } | null>(null);
const [fieldDrawer, setFieldDrawer] = useState<{ sectionKey: string; field: FormFieldConfig | null } | null>(null);
const [deleteSection, setDeleteSection] = useState<FormSectionConfig | null>(null);
const [deleteField, setDeleteField] = useState<{ sectionKey: string; field: FormFieldConfig } | null>(null);
const conditionTargets = collectConditionTargets(sections);
function mutate(next: FormSectionConfig[]) {
setSections(next);
setDirty(true);
setIssues(null);
}
function saveSection(meta: Omit<FormSectionConfig, 'fields'>) {
const editing = sectionDrawer?.section;
if (editing) {
mutate(sections.map((s) => (s.key === editing.key ? { ...s, ...meta } : s)));
} else {
mutate([...sections, { ...meta, fields: [] }]);
}
setSectionDrawer(null);
}
function saveField(field: FormFieldConfig) {
if (!fieldDrawer) return;
mutate(
sections.map((s) => {
if (s.key !== fieldDrawer.sectionKey) return s;
const exists = fieldDrawer.field;
return {
...s,
fields: exists
? s.fields.map((f) => (f.key === exists.key ? field : f))
: [...s.fields, field],
};
}),
);
setFieldDrawer(null);
}
function confirmDeleteSection() {
if (!deleteSection) return;
mutate(sections.filter((s) => s.key !== deleteSection.key));
setDeleteSection(null);
}
function confirmDeleteField() {
if (!deleteField) return;
mutate(
sections.map((s) =>
s.key === deleteField.sectionKey
? { ...s, fields: s.fields.filter((f) => f.key !== deleteField.field.key) }
: s,
),
);
setDeleteField(null);
}
async function checkForErrors() {
try {
const result = await validateSchema({
formSchema: { sections },
licenseTypeId: licenseType.id,
}).unwrap();
setIssues(result.issues);
if (result.valid) notify.success(t('certReq.schema.noIssues', 'No issues found'));
} catch (err) {
notify.error(extractErrorMessage(err));
}
}
async function handleSave() {
const ok = await run(
() => saveSchema({ id: licenseType.id, formSchema: { sections } }).unwrap(),
t('certReq.schema.saved', 'Form schema saved'),
);
if (ok) {
setDirty(false);
setIssues(null);
}
}
return (
<Stack gap="md">
<Group justify="space-between">
<Text fz="sm" c="dimmed">
{t(
'certReq.schema.subtitle',
'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.',
)}
</Text>
<Group gap="sm">
<Button variant="default" loading={validating} onClick={checkForErrors}>
{t('certReq.schema.checkErrors', 'Check for errors')}
</Button>
<Button
leftSection={<IconPlus size={15} />}
variant="default"
onClick={() => setSectionDrawer({ section: null })}
>
{t('certReq.section.add', 'Add section')}
</Button>
<Button color="teal" loading={saving} disabled={!dirty} onClick={handleSave}>
{t('certReq.schema.save', 'Save schema')}
</Button>
</Group>
</Group>
{issues !== null && issues.length > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t('certReq.schema.issuesFound', 'Issues found')}>
<Stack gap={4}>
{issues.map((issue, i) => (
<Text key={i} fz="xs">
<Text span fw={600}>{issue.path}</Text>: {issue.message}
</Text>
))}
</Stack>
</Alert>
)}
{sections.length === 0 ? (
<EmptyState
title={t('certReq.schema.empty', 'No sections yet')}
description={t('certReq.schema.emptyBody', 'Add a section to start building this licence type\'s form.')}
action={{ label: t('certReq.section.add', 'Add section'), onClick: () => setSectionDrawer({ section: null }) }}
/>
) : (
<Stack gap="md">
{sections.map((section, sIndex) => (
<Card key={section.key} withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" mb="sm">
<Group gap="xs" wrap="nowrap">
<IconGripVertical size={16} color="var(--mantine-color-gray-5)" />
<div>
<Group gap="xs">
<Text fw={700}>{localized(section.title) || section.key}</Text>
{section.group && <Badge size="xs" variant="light">{t('certReq.section.groupBadge', 'group')}: {section.group}</Badge>}
{section.showWhen && <Badge size="xs" color="violet" variant="light">{t('certReq.condition.badge', 'conditional')}</Badge>}
</Group>
<Text fz="xs" c="dimmed">key: {section.key}</Text>
</div>
</Group>
<Group gap={4}>
<ActionIcon variant="subtle" disabled={sIndex === 0} onClick={() => mutate(moveItem(sections, sIndex, -1))}>
<IconChevronUp size={14} />
</ActionIcon>
<ActionIcon variant="subtle" disabled={sIndex === sections.length - 1} onClick={() => mutate(moveItem(sections, sIndex, 1))}>
<IconChevronDown size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="blue" onClick={() => setSectionDrawer({ section })}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteSection(section)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
<Stack gap="xs">
{section.fields.map((field, fIndex) => (
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<div style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fz="sm" fw={600} truncate>{localized(field.label) || field.key}</Text>
{field.required && <Text fz="xs" c="red" fw={700}>*</Text>}
</Group>
<Group gap={6}>
<Badge size="xs" variant="light">{field.type}</Badge>
<Text fz="xs" c="dimmed" truncate>key: {field.key}</Text>
{field.showWhen && <Badge size="xs" color="violet" variant="light">{t('certReq.condition.badge', 'conditional')}</Badge>}
</Group>
</div>
</Group>
<Group gap={4} wrap="nowrap">
<ActionIcon variant="subtle" size="sm" disabled={fIndex === 0} onClick={() => mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, -1) } : s)))}>
<IconChevronUp size={13} />
</ActionIcon>
<ActionIcon variant="subtle" size="sm" disabled={fIndex === section.fields.length - 1} onClick={() => mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, 1) } : s)))}>
<IconChevronDown size={13} />
</ActionIcon>
<ActionIcon variant="subtle" size="sm" color="blue" onClick={() => setFieldDrawer({ sectionKey: section.key, field })}>
<IconEdit size={13} />
</ActionIcon>
<ActionIcon variant="subtle" size="sm" color="red" onClick={() => setDeleteField({ sectionKey: section.key, field })}>
<IconTrash size={13} />
</ActionIcon>
</Group>
</Group>
</Card>
))}
<Button
size="xs"
variant="subtle"
leftSection={<IconPlus size={13} />}
onClick={() => setFieldDrawer({ sectionKey: section.key, field: null })}
>
{t('certReq.field.add', 'Add field')}
</Button>
</Stack>
</Card>
))}
</Stack>
)}
<SectionEditorDrawer
opened={sectionDrawer !== null}
onClose={() => setSectionDrawer(null)}
section={sectionDrawer?.section ?? null}
onSave={saveSection}
palette={palette}
conditionTargets={conditionTargets}
/>
<FieldEditorDrawer
opened={fieldDrawer !== null}
onClose={() => setFieldDrawer(null)}
field={fieldDrawer?.field ?? null}
onSave={saveField}
palette={palette}
conditionTargets={conditionTargets}
/>
<Modal opened={deleteSection !== null} onClose={() => setDeleteSection(null)} title={t('certReq.section.delete', 'Delete section')} size="sm">
<Stack gap="md">
<Text fz="sm">
{t('certReq.section.deleteConfirm', 'Remove "{{name}}" and all of its fields from this schema?', {
name: deleteSection ? localized(deleteSection.title) || deleteSection.key : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteSection(null)}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="red" onClick={confirmDeleteSection}>{t('certReq.delete', 'Delete')}</Button>
</ModalFooter>
</Stack>
</Modal>
<Modal opened={deleteField !== null} onClose={() => setDeleteField(null)} title={t('certReq.field.delete', 'Delete field')} size="sm">
<Stack gap="md">
<Text fz="sm">
{t('certReq.field.deleteConfirm', 'Remove "{{name}}" from this section?', {
name: deleteField ? localized(deleteField.field.label) || deleteField.field.key : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteField(null)}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="red" onClick={confirmDeleteField}>{t('certReq.delete', 'Delete')}</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { Button, Divider, Drawer, NumberInput, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import type { FormSchemaPalette, FormSectionConfig } from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
function emptySection(): FormSectionConfig {
return { key: '', title: { en: '', am: '' }, fields: [] };
}
/** Adds/edits one section's own metadata — its fields are managed on the list, not here. */
export function SectionEditorDrawer({
opened,
onClose,
section,
onSave,
palette,
conditionTargets,
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new section. */
section: FormSectionConfig | null;
onSave: (section: Omit<FormSectionConfig, 'fields'>) => void;
palette: FormSchemaPalette | undefined;
conditionTargets: ConditionTarget[];
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<FormSectionConfig>(emptySection());
const [keyError, setKeyError] = useState<string | null>(null);
const isNew = !section;
useEffect(() => {
if (opened) {
setDraft(section ? { ...section, title: { ...section.title } } : emptySection());
setKeyError(null);
}
}, [opened, section]);
function save() {
if (!draft.key.trim() || !KEY_PATTERN.test(draft.key.trim())) {
setKeyError(t('certReq.section.keyInvalid', 'Key must start with a letter and contain only letters, numbers, underscores'));
return;
}
if (!draft.title.en?.trim()) return;
const { fields: _fields, ...meta } = draft;
onSave({ ...meta, key: draft.key.trim() });
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={<Text fw={700}>{isNew ? t('certReq.section.add', 'Add section') : t('certReq.section.edit', 'Edit section')}</Text>}
>
<Stack gap="md">
<TextInput
label={t('certReq.section.key', 'Section key')}
placeholder="certificate"
required
value={draft.key}
error={keyError}
disabled={!isNew}
description={
isNew
? t('certReq.section.keyHelp', 'Letters, numbers and underscores only')
: t('certReq.section.keyLocked', 'Key cannot change once created')
}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
/>
<BilingualInput
label={t('certReq.section.title', 'Title')}
required
value={{ en: draft.title.en ?? '', am: draft.title.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, title: v }))}
/>
<BilingualInput
label={t('certReq.section.description', 'Description')}
value={{ en: draft.description?.en ?? '', am: draft.description?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
/>
<TextInput
label={t('certReq.section.group', 'Wizard step group')}
description={t(
'certReq.section.groupHelp',
'Sections sharing the same group render together on one step',
)}
value={draft.group ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))}
/>
<NumberInput
label={t('certReq.section.groupOrder', 'Group order')}
value={draft.groupOrder ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, groupOrder: typeof v === 'number' ? v : undefined }))}
/>
<NumberInput
label={t('certReq.section.sortOrder', 'Sort order')}
value={draft.sortOrder ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : undefined }))}
/>
<Divider label={t('certReq.condition.title', 'Visibility condition')} labelPosition="left" />
<ConditionBuilder
value={(draft.showWhen ?? null) as ConditionValue | null}
onChange={(v) => setDraft((d) => ({ ...d, showWhen: v ?? undefined }))}
targets={conditionTargets}
palette={palette}
/>
<ModalFooter>
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="teal" onClick={save}>
{isNew ? t('certReq.section.add', 'Add section') : t('certReq.saveChanges', 'Save changes')}
</Button>
</ModalFooter>
</Stack>
</Drawer>
);
}

View File

@@ -0,0 +1,28 @@
import type { FormFieldConfig, FormSectionConfig } from '@ema-platform/api';
/** One field reachable by a condition's dot path, with enough of its config
* to drive the value picker (SELECT offers its options; everything else is
* free text/number/boolean). */
export interface ConditionTarget {
/** `${sectionKey}.${fieldKey}` — what `FieldCondition.field` expects. */
path: string;
field: FormFieldConfig;
}
/**
* Every field in the schema a condition could point at.
*
* Drives the condition builder's autocomplete and its options-aware value
* picker — typing `certificate.` suggests `certificate.rank` because that
* section/field exists in this licence type's own schema, not because the
* rank list is known anywhere in the frontend.
*/
export function collectConditionTargets(sections: FormSectionConfig[]): ConditionTarget[] {
const targets: ConditionTarget[] = [];
for (const section of sections) {
for (const field of section.fields ?? []) {
targets.push({ path: `${section.key}.${field.key}`, field });
}
}
return targets;
}

View File

@@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import { extractErrorMessage } from '@ema-platform/api';
/**
* Runs a mutation and reports the outcome once — the same "one path, every
* action" pattern as the certificate designer's `useDesignerActions`.
*/
export function useRequirementActions() {
const { t } = useTranslation();
return useCallback(
async (action: () => Promise<unknown>, success: string) => {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
return true;
} catch (err) {
notifications.show({
color: 'red',
title: t('certReq.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
return false;
}
},
[t],
);
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { Container, Select, Stack, Tabs } from '@mantine/core';
import { IconAlertCircle, IconFileText, IconFiles, IconSettings } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui';
import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api';
import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab';
import { FormSchemaTab } from '../components/FormSchemaTab';
/**
* Where an administrator configures what an applicant must fill in and
* upload for a licence type — the form's sections/fields (with visibility
* conditions) and its document upload slots (with conditional requirements).
*
* Not scoped to CoC/CoP specifically: every active licence type is offered,
* because a form schema and its document requirements are properties of any
* licence type, not just certificates. CoC/CoP are simply the first types an
* administrator is expected to configure this way.
*/
export function CertificateRequirementsPage() {
const { t } = useTranslation();
const localized = useLocalized();
const { data: licenseTypes, isLoading, isError, error, refetch } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null);
const options = (licenseTypes?.items ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((lt) => ({ value: lt.id, label: `${localized(lt.name)} (${lt.key})` }));
useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
}, [licenseTypes, typeId]);
const selectedType = licenseTypes?.items?.find((lt) => lt.id === typeId);
return (
<Container size="xl" py="md">
<Stack gap="md">
<PageHeader
title={t('certReq.title', 'Certificate requirements')}
subtitle={t(
'certReq.subtitle',
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
)}
/>
{isError ? (
<ErrorState
title={t('certReq.loadFailed', 'Could not load licence types')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
icon={IconAlertCircle}
/>
) : isLoading ? (
<PageLoader label={t('certReq.loading', 'Loading licence types…')} height={300} />
) : (
<Stack gap="md">
<Select
label={t('certReq.licenseType', 'Licence type')}
placeholder={t('certReq.selectLicenseType', 'Select a licence type')}
data={options}
value={typeId}
onChange={setTypeId}
searchable
leftSection={<IconSettings size={15} />}
maw={480}
/>
{selectedType && (
<Tabs defaultValue="schema" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="schema" leftSection={<IconFileText size={15} />}>
{t('certReq.tabSchema', 'Form schema')}
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<IconFiles size={15} />}>
{t('certReq.tabDocuments', 'Document requirements')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="schema" pt="md">
<FormSchemaTab licenseType={selectedType} />
</Tabs.Panel>
<Tabs.Panel value="documents" pt="md">
<DocumentRequirementsTab licenseType={selectedType} />
</Tabs.Panel>
</Tabs>
)}
</Stack>
)}
</Stack>
</Container>
);
}
export default CertificateRequirementsPage;

View File

@@ -29,6 +29,7 @@ export type ActionId =
| 'request-adjustment'
| 'reject'
| 'schedule-exam'
| 'record-exam-outcome'
| 'confirm-payment'
| 'schedule-issuance'
| 'issue-certificate'
@@ -68,7 +69,9 @@ export const ACTIONS: ActionDefinition[] = [
id: 'claim',
tier: 'workflow',
labelKey: 'review.actions.claim',
from: ['SUBMITTED'],
// Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP)
// sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED.
from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
permissions: ['can:claim:license-application'],
emphasis: 'light',
},
@@ -198,7 +201,21 @@ export const ACTIONS: ActionDefinition[] = [
// Only after the examination fee clears — scheduling an unpaid candidate
// is what the EXAM_PAID gate exists to prevent.
from: ['EXAM_PAID'],
permissions: ['can:schedule:exam-candidate'],
// Matches the controller's guard on `:id/exam-scheduled`
// (`LICENSE_PERMISSIONS.MANAGE_EXAMS`) — the previous string didn't
// correspond to any real permission constant, so this button could never
// actually be granted to anyone.
permissions: ['can:manage:exams'],
emphasis: 'filled',
color: 'cyan',
},
{
id: 'record-exam-outcome',
tier: 'primary',
labelKey: 'review.actions.recordExamOutcome',
// Only once the candidate has actually sat the exam.
from: ['EXAM_SCHEDULED'],
permissions: ['can:publish:exam-result'],
emphasis: 'filled',
color: 'cyan',
},

View File

@@ -22,7 +22,11 @@ export function licenseQueueActionsColumn(
cell: ({ row }) =>
handlers.claimable !== false &&
row.original.assignedOfficerId === null &&
row.original.status === "SUBMITTED" ? (
// Mirrors the CLAIM transition's `from` list: an examined cert
// (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears,
// not SUBMITTED.
(row.original.status === "SUBMITTED" ||
row.original.status === "ELIGIBILITY_PAID") ? (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly

View File

@@ -87,6 +87,17 @@ const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
"INSPECTION_COMPLETED",
];
/** Statuses only an examined cert (CoC, or a CoP with requiresExamination) reaches. */
const EXAM_ONLY_STATUSES: LicenseStatus[] = [
"ELIGIBILITY_PAYMENT_PENDING",
"ELIGIBILITY_PAID",
"EXAM_PAYMENT_PENDING",
"EXAM_PAID",
"EXAM_SCHEDULED",
"EXAM_PASSED",
"EXAM_FAILED",
];
const ALL_STATUSES: LicenseStatus[] = [
"SUBMITTED",
"UNDER_REVIEW",
@@ -96,9 +107,11 @@ const ALL_STATUSES: LicenseStatus[] = [
"INSPECTION_COMPLETED",
"ON_HOLD",
"APPROVED",
...EXAM_ONLY_STATUSES,
"PAYMENT_PENDING",
"PAID",
"PAYMENT_CONFIRMED",
"SCHEDULED",
"CERTIFICATE_ISSUED",
"COMPLETED",
"REJECTED",
@@ -117,6 +130,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
return type.inspectionRequired;
}
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
return true;
});

View File

@@ -44,6 +44,7 @@ import {
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleExamMutation,
useRecordExamOutcomeMutation,
useEscalateApplicationMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
@@ -209,6 +210,7 @@ export function LicenseReviewPage() {
const [issueCertificate] = useIssueCertificateMutation();
const [scheduleExam, { isLoading: schedulingExam }] =
useScheduleExamMutation();
const [recordExamOutcome] = useRecordExamOutcomeMutation();
const [holdApplication] = useHoldApplicationMutation();
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
@@ -234,6 +236,8 @@ export function LicenseReviewPage() {
const [issuanceDate, setIssuanceDate] = useState("");
const [resultOpen, setResultOpen] = useState(false);
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
const [examScore, setExamScore] = useState<number | undefined>();
const [findings, setFindings] = useState("");
const [checklist, setChecklist] = useState<
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
@@ -490,6 +494,11 @@ export function LicenseReviewPage() {
case "schedule-exam":
setScheduleExamOpen(true);
return;
// Pass/fail plus an optional score, same reasoning as schedule-exam:
// needs its own inputs before anything is sent.
case "record-exam-outcome":
setExamOutcomeOpen(true);
return;
case "copy-link":
navigator.clipboard.writeText(window.location.href);
notifications.show({
@@ -1173,6 +1182,73 @@ export function LicenseReviewPage() {
}}
/>
<Modal
opened={examOutcomeOpen}
onClose={() => setExamOutcomeOpen(false)}
title={t("review.actions.recordExamOutcome", "Record exam outcome")}
>
<Stack>
<Text size="sm" c="dimmed">
{t(
"review.examOutcome.intro",
"Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.",
)}
</Text>
<NumberInput
label={t("review.examOutcome.score", "Score (optional)")}
value={examScore}
onChange={(v) => setExamScore(typeof v === "number" ? v : undefined)}
min={0}
/>
<ModalFooter grow>
<ActionIcon
variant="light"
color="teal"
size="lg"
aria-label={t("review.passed", "Passed")}
onClick={() =>
run(
async () => {
await recordExamOutcome({
id,
passed: true,
score: examScore,
}).unwrap();
setExamOutcomeOpen(false);
setExamScore(undefined);
},
t("review.done.examPassed", "Exam result recorded — passed"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="lg"
aria-label={t("review.failed", "Failed")}
onClick={() =>
run(
async () => {
await recordExamOutcome({
id,
passed: false,
score: examScore,
}).unwrap();
setExamOutcomeOpen(false);
setExamScore(undefined);
},
t("review.done.examFailed", "Exam result recorded — not passed"),
)
}
>
<IconX size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}

View File

@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { ActiveSessions, setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
@@ -126,9 +126,9 @@ export function ProfilePage() {
.string()
.min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z
.string()
.min(1, { message: t('profile.validation.phoneRequired') }),
// Shared international rule: bare 09xxxxxxxx normalizes to +251, any
// other E.164 number is accepted as typed.
phoneNumber,
});
type ProfileValues = z.infer<typeof profileSchema>;

View File

@@ -33,6 +33,7 @@ export const am: Translations = {
allApplications: "ሁሉም ማመልከቻዎች",
licenceRegister: "የፈቃድ መዝገብ",
certificateDesigner: "የምስክር ወረቀት ንድፍ",
certificateRequirements: "የምስክር ወረቀት መስፈርቶች",
byType: "በዓይነት",
typeFreightForwarder: "የጭነት አስተላላፊ",
typeShippingAgent: "የመርከብ ወኪል",
@@ -1201,6 +1202,124 @@ export const am: Translations = {
noPublishPermission: "ንድፎችን ማተም አይችሉም",
},
certReq: {
title: "የምስክር ወረቀት መስፈርቶች",
subtitle:
"ለፈቃድ ዓይነት አመልካቾች መሙላት ያለባቸውን የቅጽ መስኮች እና የሰነድ ሰቀላዎች ያዋቅሩ፣ መስፈርቱ መቼ ተግባራዊ እንደሚሆንም ጨምሮ።",
licenseType: "የፈቃድ ዓይነት",
selectLicenseType: "የፈቃድ ዓይነት ይምረጡ",
loading: "የፈቃድ ዓይነቶችን በመጫን ላይ…",
loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም",
tabSchema: "የቅጽ ቅንብር",
tabDocuments: "የሰነድ መስፈርቶች",
cancel: "ይቅር",
delete: "ሰርዝ",
saveChanges: "ለውጦችን አስቀምጥ",
actionFailed: "ተግባሩ አልተሳካም",
condition: {
title: "የሚታይበት ሁኔታ",
badge: "ሁኔታዊ",
enable: "ሁኔታ ሲሟላ ብቻ ተግባራዊ ይሁን",
field: "የመስክ መንገድ",
fieldHelp: "ወደ ቅጹ የነጥብ መንገድ፣ ለምሳሌ sectionKey.fieldKey",
operator: "አመልካች",
value: "ዋጋ",
values: "ከእነዚህ አንዱ",
removeValue: "ለማስወገድ ይጫኑ",
isSet: "ተሞልቷል",
isNotSet: "አልተሞላም",
unknownField: "ይህ መንገድ በአሁኑ ቅንብር ውስጥ ያለ መስክ አይደለም — ቢሆንም እንደተጻፈው ይቀመጣል።",
},
schema: {
subtitle:
"አመልካቹ ለዚህ የፈቃድ ዓይነት የሚያየው ክፍሎች እና መስኮች። አንድ ቡድን የሚጋሩ ክፍሎች በአንድ የዊዛርድ ደረጃ ላይ አብረው ይታያሉ።",
checkErrors: "ስህተቶችን ፈትሽ",
noIssues: "ምንም ችግር አልተገኘም",
issuesFound: "ችግሮች ተገኝተዋል",
save: "ቅንብር አስቀምጥ",
saved: "የቅጽ ቅንብር ተቀምጧል",
empty: "እስካሁን ክፍል የለም",
emptyBody: "ለዚህ የፈቃድ ዓይነት ቅጽ ለመገንባት ክፍል ይጨምሩ።",
},
section: {
add: "ክፍል ጨምር",
edit: "ክፍል አርትዕ",
delete: "ክፍል ሰርዝ",
deleteConfirm: '"{{name}}"ን እና ሁሉንም መስኮቹን ከዚህ ቅንብር ማስወገድ ይፈልጋሉ?',
key: "የክፍል ቁልፍ",
keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ",
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
title: "ርዕስ",
description: "መግለጫ",
group: "የዊዛርድ ደረጃ ቡድን",
groupHelp: "አንድ ቡድን የሚጋሩ ክፍሎች በአንድ ደረጃ ላይ አብረው ይታያሉ",
groupBadge: "ቡድን",
groupOrder: "የቡድን ቅደም ተከተል",
sortOrder: "የቅደም ተከተል ቁጥር",
},
field: {
add: "መስክ ጨምር",
edit: "መስክ አርትዕ",
delete: "መስክ ሰርዝ",
deleteConfirm: '"{{name}}"ን ከዚህ ክፍል ማስወገድ ይፈልጋሉ?',
key: "የመስክ ቁልፍ",
keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ — የቅጽ መረጃ ቁልፍ ይሆናል",
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
label: "መለያ",
type: "የመስክ ዓይነት",
required: "የግድ ያስፈልጋል",
placeholder: "ምሳሌ ጽሑፍ",
helpText: "የእገዛ ጽሑፍ",
min: "ዝቅተኛ",
max: "ከፍተኛ",
maxLength: "ከፍተኛ ርዝመት",
options: "አማራጮች",
addOption: "አማራጭ ጨምር",
optionValue: "ዋጋ",
optionLabel: "መለያ",
},
doc: {
subtitle: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለበት፣ በአዲስ ማመልከቻ እና በዕድሳት የተከፈለ።",
add: "የሰነድ መስፈርት ጨምር",
edit: "የሰነድ መስፈርት አርትዕ",
delete: "የሰነድ መስፈርት ሰርዝ",
deleteWarning: "በዚህ ቦታ ላይ የሚተማመኑ አመልካቾች ከዚህ በኋላ አያዩትም። ይህ መመለስ አይቻልም።",
deleteConfirm: '"{{name}}"ን ማስወገድ ይፈልጋሉ?',
created: "የሰነድ መስፈርት ተጨምሯል",
updated: "የሰነድ መስፈርት ተዘምኗል",
deleted: "የሰነድ መስፈርት ተወግዷል",
loading: "የሰነድ መስፈርቶችን በመጫን ላይ…",
loadFailed: "የሰነድ መስፈርቶችን መጫን አልተቻለም",
empty: "ምንም የሰነድ መስፈርት አልተዋቀረም",
emptyBody: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለባቸውን ሰነዶች ይጨምሩ።",
emptyKind: "ለዚህ የማመልከቻ ዓይነት እስካሁን የሰነድ መስፈርት የለም።",
key: "ቁልፍ",
keyHelp: "ይህን የሰነድ ቦታ የሚለይ ቋሚ መጠሪያ",
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
keyRequired: "ቁልፍ ያስፈልጋል",
name: "ስም",
description: "መግለጫ",
applicationKind: "የማመልከቻ ዓይነት",
kindNew: "አዲስ ማመልከቻ",
kindRenewal: "ዕድሳት",
kindLocked: "ከተፈጠረ በኋላ የማመልከቻ ዓይነት መቀየር አይቻልም",
mode: "ዘዴ",
modeAlways: "ሁልጊዜ ያስፈልጋል",
modeConditional: "ሁኔታ ሲሟላ ያስፈልጋል",
modeOptional: "አማራጭ ስቀላ",
conditionRequired: "ሁኔታዊ መስፈርት ሁኔታ ያስፈልገዋል",
allowedTypes: "የተፈቀዱ የፋይል ዓይነቶች",
maxSize: "ከፍተኛ የፋይል መጠን (MB)",
requiresValidity: "የቀን ገደብ ያስፈልጋል",
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
multiple: "ብዙ",
sortOrder: "የቅደም ተከተል ቁጥር",
when: "መቼ",
},
},
seafarerRegistry: {
title: "የመርከበኞች መዝገብ",
profileCount_one: "{{count}} መገለጫ",

View File

@@ -32,6 +32,7 @@ export const en = {
allApplications: 'All Applications',
licenceRegister: 'Licence Register',
certificateDesigner: 'Certificate Designer',
certificateRequirements: 'Certificate Requirements',
byType: 'By Type',
typeFreightForwarder: 'Freight Forwarder',
typeShippingAgent: 'Shipping Agent',
@@ -1204,6 +1205,124 @@ export const en = {
noPublishPermission: 'You cannot publish designs',
},
certReq: {
title: 'Certificate requirements',
subtitle:
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
licenseType: 'Licence type',
selectLicenseType: 'Select a licence type',
loading: 'Loading licence types…',
loadFailed: 'Could not load licence types',
tabSchema: 'Form schema',
tabDocuments: 'Document requirements',
cancel: 'Cancel',
delete: 'Delete',
saveChanges: 'Save changes',
actionFailed: 'Action failed',
condition: {
title: 'Visibility condition',
badge: 'conditional',
enable: 'Only apply when a condition holds',
field: 'Field path',
fieldHelp: 'Dot path into the form, e.g. sectionKey.fieldKey',
operator: 'Operator',
value: 'Value',
values: 'Any of',
removeValue: 'Click to remove',
isSet: 'is set',
isNotSet: 'is not set',
unknownField: 'This path is not a field in the current schema yet — it will still be saved as typed.',
},
schema: {
subtitle:
'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.',
checkErrors: 'Check for errors',
noIssues: 'No issues found',
issuesFound: 'Issues found',
save: 'Save schema',
saved: 'Form schema saved',
empty: 'No sections yet',
emptyBody: "Add a section to start building this licence type's form.",
},
section: {
add: 'Add section',
edit: 'Edit section',
delete: 'Delete section',
deleteConfirm: 'Remove "{{name}}" and all of its fields from this schema?',
key: 'Section key',
keyHelp: 'Letters, numbers and underscores only',
keyLocked: 'Key cannot change once created',
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
title: 'Title',
description: 'Description',
group: 'Wizard step group',
groupHelp: 'Sections sharing the same group render together on one step',
groupBadge: 'group',
groupOrder: 'Group order',
sortOrder: 'Sort order',
},
field: {
add: 'Add field',
edit: 'Edit field',
delete: 'Delete field',
deleteConfirm: 'Remove "{{name}}" from this section?',
key: 'Field key',
keyHelp: 'Letters, numbers and underscores only — becomes the form data key',
keyLocked: 'Key cannot change once created',
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
label: 'Label',
type: 'Field type',
required: 'Required',
placeholder: 'Placeholder',
helpText: 'Help text',
min: 'Minimum',
max: 'Maximum',
maxLength: 'Max length',
options: 'Options',
addOption: 'Add option',
optionValue: 'Value',
optionLabel: 'Label',
},
doc: {
subtitle: 'What an applicant must upload for this licence type, split by new application and renewal.',
add: 'Add document requirement',
edit: 'Edit document requirement',
delete: 'Delete document requirement',
deleteWarning: 'Applicants already relying on this slot will no longer see it. This cannot be undone.',
deleteConfirm: 'Remove "{{name}}"?',
created: 'Document requirement added',
updated: 'Document requirement updated',
deleted: 'Document requirement removed',
loading: 'Loading document requirements…',
loadFailed: 'Could not load document requirements',
empty: 'No document requirements configured',
emptyBody: 'Add the documents an applicant must upload for this licence type.',
emptyKind: 'No document requirements for this application kind yet.',
key: 'Key',
keyHelp: 'Stable slug identifying this document slot',
keyLocked: 'Key cannot change once created',
keyRequired: 'Key is required',
name: 'Name',
description: 'Description',
applicationKind: 'Application kind',
kindNew: 'New application',
kindRenewal: 'Renewal',
kindLocked: 'Application kind cannot change once created',
mode: 'Mode',
modeAlways: 'Always required',
modeConditional: 'Required when condition holds',
modeOptional: 'Optional upload',
conditionRequired: 'A conditional requirement needs a condition',
allowedTypes: 'Allowed file types',
maxSize: 'Max file size (MB)',
requiresValidity: 'Requires validity dates',
allowMultiple: 'Allow multiple uploads',
multiple: 'multiple',
sortOrder: 'Sort order',
when: 'when',
},
},
seafarerRegistry: {
title: 'Seafarer registry',
profileCount_one: '{{count}} profile',

View File

@@ -4,6 +4,7 @@ import {
IconBook2,
IconChartBar,
IconClipboardList,
IconClipboardText,
IconCreditCard,
IconFileDescription,
IconFilePlus,
@@ -144,6 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconRosetteDiscountCheck,
permissions: [P.VIEW_TEMPLATES],
},
{
to: '/certificate-requirements',
label: 'nav.certificateRequirements',
icon: IconClipboardText,
permissions: [P.VIEW_LICENSE_TYPES],
},
{
to: '/payment-config',
label: 'nav.paymentConfig',

View File

@@ -46,6 +46,7 @@ import { LicenseRegisterPage } from '../features/license-register/pages/LicenseR
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
/** Any-of gate shared by every licence-type queue and its review workspace. */
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
@@ -114,6 +115,8 @@ const router = createBrowserRouter([
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
// Config-driven review workspace, shared by every licence type.
{ path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
// Form schema + document requirement authoring, shared by every licence type.
{ path: 'certificate-requirements', element: guard([P.VIEW_LICENSE_TYPES], <CertificateRequirementsPage />) },
{ path: 'licence-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
// Deep link into the grid with the type facet pinned, so "Freight

View File

@@ -36,6 +36,7 @@ import {
useGetMyMedicalCertificatesQuery,
} from '@ema-platform/api';
import { PdfPreviewModal } from '@ema-platform/ui';
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
// ---------------------------------------------------------------------------
// Mock data
@@ -55,6 +56,9 @@ interface CertificatesOverview {
type: string;
submitted: string;
status: string;
/** The fee owed at the current status, or null when nothing is due. */
feeAmount: number | null;
feeCurrency: string | null;
}[];
}
@@ -72,7 +76,8 @@ const STATUS_COLOR: Record<string, string> = {
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'grape',
INSPECTION_COMPLETED: 'grape',
ELIGIBILITY_APPROVED: 'teal',
ELIGIBILITY_PAYMENT_PENDING: 'orange',
ELIGIBILITY_PAID: 'blue',
EXAM_PAYMENT_PENDING: 'orange',
EXAM_PAID: 'blue',
EXAM_SCHEDULED: 'indigo',
@@ -140,6 +145,7 @@ export function CertificatesPage() {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewTitle, setPreviewTitle] = useState('');
const [loading, setLoading] = useState(false);
const { pay, isPaying } = useApplicationPayment();
const { data } = useApiQuery<CertificatesOverview>({
url: '/certificates/my',
@@ -220,14 +226,25 @@ export function CertificatesPage() {
{/* Tooltip needs a hoverable child even while the button itself is
disabled, so the reason still shows on hover. */}
<span>
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/certificates/apply')}
disabled={!canApply}
>
Apply for CoC / CoP
</Button>
<Group gap="xs">
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
disabled={!canApply}
>
Apply for CoC
</Button>
<Button
variant="light"
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')}
disabled={!canApply}
>
Apply for CoP
</Button>
</Group>
</span>
</Tooltip>
</Group>
@@ -269,7 +286,7 @@ export function CertificatesPage() {
<Text fw={700} mb="md">My Applications</Text>
{applications.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
No active CoC/CoP applications. Click "Apply for CoC" or "Apply for CoP" to start.
</Alert>
) : (
<Table highlightOnHover fz="sm" verticalSpacing="sm">
@@ -301,14 +318,28 @@ export function CertificatesPage() {
</Badge>
</Table.Td>
<Table.Td>
<Text
fz="xs"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/applications/${app.applicationId}`)}
>
Details
</Text>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* feeAmount is non-null exactly when the server would
accept a payment, so the button and the API agree. */}
{app.feeAmount !== null && (
<Button
size="xs"
color="yellow"
loading={isPaying}
onClick={() => pay(app.applicationId)}
>
Pay {app.feeAmount.toLocaleString()} {app.feeCurrency}
</Button>
)}
<Text
fz="xs"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/applications/${app.applicationId}`)}
>
Details
</Text>
</Group>
</Table.Td>
</Table.Tr>
))}

View File

@@ -14,7 +14,7 @@ import {
type FormSectionConfig,
type Vessel,
} from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
import { LocationPicker } from '../../location/components/LocationPicker';
@@ -223,6 +223,12 @@ export function ConfigDrivenSection({
value={(value as string) ?? ''}
onChange={(v) => onChange(field.key, v)}
/>
) : field.type === 'PHONE' ? (
<PhoneInput
{...common}
value={(value as string) ?? ''}
onChange={(v) => onChange(field.key, v)}
/>
) : field.type === 'TEXTAREA' ? (
<Textarea
{...common}

View File

@@ -144,6 +144,11 @@ export function DocumentSlots({
</Badge>
)}
</Group>
{requirement.description && (
<Text size="xs" c="dimmed" mt={2}>
{localized(requirement.description)}
</Text>
)}
{existing?.files?.[0] && (
<Text size="xs" c="dimmed" truncate>
{existing.files[0].originalName} ·{' '}

View File

@@ -7,12 +7,13 @@ interface Props {
t: TFunction;
requesting: boolean;
paying: boolean;
onRequestExamFee: (app: LicenseApplication) => void;
onRetakeExam: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
}
/**
* What the candidate can do while an examined certificate is in its exam leg.
* What the candidate can do while an examined certificate is in its
* eligibility or exam leg.
*
* Kept apart from the general actions column because these statuses only ever
* occur on types that examine — folding them into that column would put five
@@ -26,24 +27,50 @@ export function ExamStageActions({
t,
requesting,
paying,
onRequestExamFee,
onRetakeExam,
onPay,
}: Props) {
// Eligible but not yet committed to sitting, or sat and not passed: both are
// the same decision — ask for the fee that buys a sitting.
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
const retake = app.status === 'EXAM_FAILED';
// The eligibility fee is invoiced the moment the application is submitted —
// there is no separate "request" step, so this is a pay button, exactly
// like EXAM_PAYMENT_PENDING below.
if (app.status === 'ELIGIBILITY_PAYMENT_PENDING') {
return (
<Button
size="xs"
variant="filled"
color={retake ? 'orange' : 'teal'}
loading={requesting}
onClick={() => onRequestExamFee(app)}
color="yellow"
loading={paying}
onClick={() => onPay(app)}
>
{retake
? t('applications.actions.bookRetake', 'Book a resit')
: t('applications.actions.bookExam', 'Book exam')}
{t('applications.actions.payEligibilityFee', {
defaultValue: 'Pay eligibility fee ({{amount}} {{currency}})',
amount: Number(app.feeAmount ?? 0).toLocaleString(),
currency: app.feeCurrency,
})}
</Button>
);
}
// Paid — queued for backoffice review. Nothing for the candidate to do.
if (app.status === 'ELIGIBILITY_PAID') {
return (
<Button size="xs" variant="subtle" disabled>
{t('applications.actions.eligibilityUnderReview', 'Under review')}
</Button>
);
}
// Failed a sitting: the only decision left is whether to pay for another.
if (app.status === 'EXAM_FAILED') {
return (
<Button
size="xs"
variant="filled"
color="orange"
loading={requesting}
onClick={() => onRetakeExam(app)}
>
{t('applications.actions.bookRetake', 'Book a resit')}
</Button>
);
}
@@ -66,6 +93,25 @@ export function ExamStageActions({
);
}
// Passed: the certificate itself is the last fee on the application.
if (app.status === 'EXAM_PASSED') {
return (
<Button
size="xs"
variant="filled"
color="yellow"
loading={paying}
onClick={() => onPay(app)}
>
{t('applications.actions.payCertificateFee', {
defaultValue: 'Pay certificate fee ({{amount}} {{currency}})',
amount: Number(app.feeAmount ?? 0).toLocaleString(),
currency: app.feeCurrency,
})}
</Button>
);
}
// Paid and scheduled are both waiting states — nothing for the candidate to
// do, so say so rather than offering a button that does nothing.
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {

View File

@@ -270,13 +270,18 @@ export function LicenseApplicationPage() {
const nameFallback = accountName?.en
? splitPersonName(accountName.en)
: null;
const firstName = profile.firstName || nameFallback?.firstName || "";
const middleName = profile.middleName || nameFallback?.middleName || "";
const lastName = profile.lastName || nameFallback?.lastName || "";
const context = {
user: accountUser ?? profile.user,
profile: {
...profile,
firstName: profile.firstName || nameFallback?.firstName || "",
middleName: profile.middleName || nameFallback?.middleName || "",
lastName: profile.lastName || nameFallback?.lastName || "",
firstName,
middleName,
lastName,
// The profile has no single "full name" column — it's first/middle/last.
fullName: [firstName, middleName, lastName].filter(Boolean).join(" "),
},
};
@@ -291,7 +296,12 @@ export function LicenseApplicationPage() {
const untouched =
current === undefined || current === null || current === "";
if (!field.readOnly && !untouched) continue;
const value = readSourcePath(context, source);
const raw = readSourcePath(context, source);
// Profile dates arrive as ISO datetimes; a DATE field's picker wants
// yyyy-MM-dd. Seafarer registration's `profile.dob` source hits the
// same mismatch today — fixed once here rather than per config.
const value =
field.type === "DATE" && typeof raw === "string" ? raw.slice(0, 10) : raw;
if (value === undefined || value === null || value === "") continue;
if (current === value) continue;
next[section.key] = { ...next[section.key], [field.key]: value };
@@ -727,9 +737,14 @@ export function LicenseApplicationPage() {
return (
<div key={section.key}>
{index > 0 && <Divider mb="lg" />}
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb={section.description ? 4 : "sm"}>
{localized(section.title)}
</Text>
{section.description && (
<Text fz="xs" c="dimmed" mb="sm">
{localized(section.description)}
</Text>
)}
{locked && (
<Alert
color="gray"

View File

@@ -8,11 +8,13 @@ import { ExamStageActions } from '../../components/ExamStageActions';
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
const EXAM_STAGE_STATUSES = [
'ELIGIBILITY_APPROVED',
'ELIGIBILITY_PAYMENT_PENDING',
'ELIGIBILITY_PAID',
'EXAM_PAYMENT_PENDING',
'EXAM_PAID',
'EXAM_SCHEDULED',
'EXAM_FAILED',
'EXAM_PASSED',
];
export function applicationActionsColumn(
@@ -23,12 +25,12 @@ export function applicationActionsColumn(
bypassEnabled: boolean;
bypassing: boolean;
isPaying: boolean;
/** True while the exam fee is being raised for a booking or a resit. */
/** True while a resit is being requested. */
requestingExamFee: boolean;
onBypass: (app: LicenseApplication) => void;
onCertificate: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
onRequestExamFee: (app: LicenseApplication) => void;
onRetakeExam: (app: LicenseApplication) => void;
onOpen: (app: LicenseApplication) => void;
},
): AdvancedColumn<LicenseApplication> {
@@ -46,14 +48,17 @@ export function applicationActionsColumn(
t={t}
requesting={deps.requestingExamFee}
paying={deps.isPaying}
onRequestExamFee={deps.onRequestExamFee}
onRetakeExam={deps.onRetakeExam}
onPay={deps.onPay}
/>
{/* Both fee stops are bypassable — an examined certificate is
{/* Every fee stop is bypassable — an examined certificate charges
three separate fees (eligibility, exam, certificate) and is
otherwise untestable without a live gateway. */}
{deps.bypassEnabled &&
(app.status === 'PAYMENT_PENDING' ||
app.status === 'EXAM_PAYMENT_PENDING') && (
app.status === 'ELIGIBILITY_PAYMENT_PENDING' ||
app.status === 'EXAM_PAYMENT_PENDING' ||
app.status === 'EXAM_PASSED') && (
<Button
size="xs"
variant="default"

View File

@@ -46,7 +46,7 @@ import {
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
useRequestExamPaymentMutation,
useRetakeExamMutation,
type LicenseStatus,
} from '@ema-platform/api';
import {
@@ -99,8 +99,7 @@ export function MyApplicationsPage() {
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [requestExamPayment, { isLoading: requestingExamFee }] =
useRequestExamPaymentMutation();
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
@@ -135,15 +134,15 @@ export function MyApplicationsPage() {
}
/**
* Raises the examination fee, for a first sitting or a resit.
* Re-opens the examination fee for a failed candidate.
*
* Payment is a separate step: this only moves the application to
* Payment is a separate step: this only moves the application back to
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
* the provider the same way every other fee does.
*/
async function requestExamFee(applicationId: string) {
async function retakeExamFee(applicationId: string) {
try {
await requestExamPayment(applicationId).unwrap();
await retakeExam(applicationId).unwrap();
notifications.show({
color: 'teal',
title: t('applications.examFeeRequested', 'Exam fee ready'),
@@ -281,7 +280,7 @@ export function MyApplicationsPage() {
onBypass: (app) => handleBypass(app.id),
onCertificate: (app) => openCertificateForApplication(app.id),
onPay: (app) => pay(app.id),
onRequestExamFee: (app) => requestExamFee(app.id),
onRetakeExam: (app) => retakeExamFee(app.id),
onOpen: (app) =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
}),

View File

@@ -4,7 +4,7 @@ import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFo
import { z } from 'zod';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
import { phoneNumber, optionalPhoneNumber, CountrySelect, PhoneInput } from '@ema-platform/ui';
import { LocationPicker } from '../../location/components/LocationPicker';
import { useGetLocationTypesQuery } from '../../location/api/location-api';
import type { Location, LocationType } from '../../location/types/location';
@@ -17,8 +17,8 @@ export function addressSchema(t: TFunction) {
idNumber: z.string().trim().min(1, t('profileAddress.validation.idNumberRequired')),
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
primaryPhoneNumber: ethiopianPhone,
secondaryPhoneNumber: optionalEthiopianPhone,
primaryPhoneNumber: phoneNumber,
secondaryPhoneNumber: optionalPhoneNumber,
email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
@@ -30,7 +30,7 @@ export function addressSchema(t: TFunction) {
// Emergency contact is collected but never required — leaving it blank must
// not stop an applicant moving on.
emergencyContactName: z.string().trim().optional(),
emergencyContactPhone: optionalEthiopianPhone,
emergencyContactPhone: optionalPhoneNumber,
emergencyContactRelation: z.string().trim().optional(),
});
}
@@ -147,18 +147,22 @@ export function AddressFormContent({
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
error={errors.nationality?.message}
/>
<TextInput
<PhoneInput
label={t('profileFields.primaryPhoneNumber')}
description={t('profileAddress.accountManagedHint')}
required
readOnly
{...register('primaryPhoneNumber')}
value={watch('primaryPhoneNumber') || ''}
onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: !!errors.primaryPhoneNumber })}
onBlur={() => trigger('primaryPhoneNumber')}
error={errors.primaryPhoneNumber?.message}
/>
<TextInput
<PhoneInput
label={t('profileAddress.secondaryPhoneNumber')}
placeholder={t('profileAddress.phonePlaceholder')}
{...register('secondaryPhoneNumber')}
value={watch('secondaryPhoneNumber') || ''}
onChange={(val) => setValue('secondaryPhoneNumber', val, { shouldValidate: !!errors.secondaryPhoneNumber })}
onBlur={() => trigger('secondaryPhoneNumber')}
error={errors.secondaryPhoneNumber?.message}
/>
<TextInput
@@ -206,10 +210,12 @@ export function AddressFormContent({
{...register('emergencyContactName')}
error={errors.emergencyContactName?.message}
/>
<TextInput
<PhoneInput
label={t('profileAddress.contactPhone')}
placeholder={t('profileAddress.phonePlaceholder')}
{...register('emergencyContactPhone')}
value={watch('emergencyContactPhone') || ''}
onChange={(val) => setValue('emergencyContactPhone', val, { shouldValidate: !!errors.emergencyContactPhone })}
onBlur={() => trigger('emergencyContactPhone')}
error={errors.emergencyContactPhone?.message}
/>
<TextInput

View File

@@ -15,7 +15,12 @@ function isAtLeast18(dob: string): boolean {
return birth <= cutoff;
}
export const profileSchema = (t: TFunction) =>
// Printed on the Seaman Book, so a seafarer account can't leave them blank —
// every other account type may. Blood type offers UNKNOWN, so requiring an
// answer never forces a claim. Mirrors seafarer-registration.seed-data.ts's
// `required: true` on the same fields, and the backend's own check in
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
export const profileSchema = (t: TFunction, isSeafarer: boolean) =>
z.object({
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
@@ -28,8 +33,32 @@ export const profileSchema = (t: TFunction) =>
.refine((value) => isAtLeast18(value), {
message: t('profileForm.validation.dobMinAge'),
}),
pob: z.string().optional(),
pob: isSeafarer
? z.string().min(1, t('profileForm.validation.pobRequired'))
: z.string().optional(),
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
bloodType: isSeafarer
? z.string().min(1, t('profileForm.validation.bloodTypeRequired'))
: z.string().optional(),
hairColor: isSeafarer
? z.string().min(1, t('profileForm.validation.hairColorRequired'))
: z.string().optional(),
eyeColor: isSeafarer
? z.string().min(1, t('profileForm.validation.eyeColorRequired'))
: z.string().optional(),
heightCm: isSeafarer
? z
.string()
.min(1, t('profileForm.validation.heightRequired'))
.refine((v) => Number(v) >= 100 && Number(v) <= 250, {
message: t('profileForm.validation.heightRange'),
})
: z
.string()
.optional()
.refine((v) => !v || (Number(v) >= 100 && Number(v) <= 250), {
message: t('profileForm.validation.heightRange'),
}),
});
export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
@@ -37,6 +66,13 @@ export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
export const GENDERS = ['MALE', 'FEMALE'] as const;
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
// Must match EBloodType/EHairColor/EEyeColor on the backend (common/enums/user.enum.ts).
export const BLOOD_TYPES = [
'A_POSITIVE', 'A_NEGATIVE', 'B_POSITIVE', 'B_NEGATIVE',
'AB_POSITIVE', 'AB_NEGATIVE', 'O_POSITIVE', 'O_NEGATIVE', 'UNKNOWN',
] as const;
export const HAIR_COLORS = ['BLACK', 'BROWN', 'BLONDE', 'RED', 'GREY', 'WHITE', 'BALD', 'OTHER'] as const;
export const EYE_COLORS = ['BROWN', 'BLACK', 'BLUE', 'GREEN', 'HAZEL', 'GREY', 'OTHER'] as const;
interface ProfileFormContentProps {
register: UseFormRegister<ProfileValues>;
@@ -46,6 +82,8 @@ interface ProfileFormContentProps {
trigger: UseFormTrigger<ProfileValues>;
professionsLoading: boolean;
professionOptions: Array<{ value: string; label: string }>;
/** Place of birth, hair/eye colour and height become required for these accounts. */
isSeafarer: boolean;
}
export function ProfileFormContent({
@@ -56,6 +94,7 @@ export function ProfileFormContent({
trigger,
professionsLoading,
professionOptions,
isSeafarer,
}: ProfileFormContentProps) {
const { t } = useTranslation();
@@ -119,6 +158,7 @@ export function ProfileFormContent({
<TextInput
label={t('profileFields.pob')}
placeholder={t('profileForm.placeholders.pob')}
required={isSeafarer}
{...register('pob')}
error={errors.pob?.message}
/>
@@ -133,6 +173,50 @@ export function ProfileFormContent({
onBlur={() => trigger('maritalStatus')}
name="maritalStatus"
/>
<Select
label={t('profileFields.bloodType')}
placeholder={t('common.select')}
required={isSeafarer}
clearable={!isSeafarer}
data={BLOOD_TYPES.map((b) => ({ value: b, label: t(`profileForm.bloodTypes.${b}`) }))}
error={errors.bloodType?.message}
value={watch('bloodType') || null}
onChange={(val) => setValue('bloodType', val || '', { shouldValidate: true })}
onBlur={() => trigger('bloodType')}
name="bloodType"
/>
<TextInput
label={t('profileFields.heightCm')}
placeholder={t('profileForm.placeholders.heightCm')}
type="number"
required={isSeafarer}
{...register('heightCm')}
error={errors.heightCm?.message}
/>
<Select
label={t('profileFields.hairColor')}
placeholder={t('common.select')}
required={isSeafarer}
clearable={!isSeafarer}
data={HAIR_COLORS.map((h) => ({ value: h, label: t(`profileForm.hairColors.${h}`) }))}
error={errors.hairColor?.message}
value={watch('hairColor') || null}
onChange={(val) => setValue('hairColor', val || '', { shouldValidate: true })}
onBlur={() => trigger('hairColor')}
name="hairColor"
/>
<Select
label={t('profileFields.eyeColor')}
placeholder={t('common.select')}
required={isSeafarer}
clearable={!isSeafarer}
data={EYE_COLORS.map((e) => ({ value: e, label: t(`profileForm.eyeColors.${e}`) }))}
error={errors.eyeColor?.message}
value={watch('eyeColor') || null}
onChange={(val) => setValue('eyeColor', val || '', { shouldValidate: true })}
onBlur={() => trigger('eyeColor')}
name="eyeColor"
/>
</SimpleGrid>
);
}

View File

@@ -37,7 +37,6 @@ import {
IconBuildingWarehouse,
IconMapPin,
IconMoon,
IconPhone,
IconSettings,
IconShieldLock,
IconSun,
@@ -49,7 +48,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { useApiMutation, useLocalized } from '@ema-platform/api';
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
@@ -187,6 +186,12 @@ export function ProfilePage() {
const showSeafarerBanner =
can([PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]) &&
!isReadyFor(SEAFARER_PROFILE_REQUIREMENT);
// Place of birth, blood type, hair/eye colour and height print on the
// Seaman Book, so a seafarer account can't leave them blank — every other
// account type may. Mirrors the seafarer-registration wizard's
// `required: true` on the same fields and the backend check in
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
const isSeafarer = (resolvedProfile ?? storedProfile)?.type === 'SEAFARER';
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
@@ -224,6 +229,10 @@ export function ProfilePage() {
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
pob: currentProfile.pob || '',
maritalStatus: currentProfile.maritalStatus || '',
bloodType: currentProfile.bloodType || '',
hairColor: currentProfile.hairColor || '',
eyeColor: currentProfile.eyeColor || '',
heightCm: currentProfile.heightCm != null ? String(currentProfile.heightCm) : '',
});
// Primary phone and email are the account's contact details (same
@@ -270,7 +279,7 @@ export function ProfilePage() {
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
phoneNumber,
});
type PersonalValues = z.infer<typeof personalSchema>;
@@ -278,6 +287,9 @@ export function ProfilePage() {
register: registerPersonal,
handleSubmit: handlePersonalSubmit,
reset: resetPersonal,
watch: watchPersonal,
setValue: setValuePersonal,
trigger: triggerPersonal,
formState: { errors: personalErrors },
} = useForm<PersonalValues>({
resolver: zodResolver(personalSchema),
@@ -365,7 +377,7 @@ export function ProfilePage() {
trigger: profileTriggerValidation,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema(t)),
resolver: zodResolver(profileSchema(t, isSeafarer)),
values: loadedProfile ?? undefined,
});
@@ -383,7 +395,15 @@ export function ProfilePage() {
await updateProfile({
url: `/profiles/${profileId}`,
method: 'PUT',
body: values,
// Empty string is not a valid enum value on the backend — an
// untouched Select must clear the column, not fail validation.
body: {
...values,
heightCm: values.heightCm ? Number(values.heightCm) : null,
bloodType: values.bloodType || null,
hairColor: values.hairColor || null,
eyeColor: values.eyeColor || null,
},
}).unwrap();
setLoadedProfile({ ...values, pob: values.pob ?? '' });
@@ -669,11 +689,12 @@ export function ProfilePage() {
error={personalErrors.email?.message}
{...registerPersonal('email')}
/>
<TextInput
<PhoneInput
label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />}
value={watchPersonal('phoneNumber') || ''}
onChange={(val) => setValuePersonal('phoneNumber', val, { shouldValidate: !!personalErrors.phoneNumber })}
onBlur={() => triggerPersonal('phoneNumber')}
error={personalErrors.phoneNumber?.message}
{...registerPersonal('phoneNumber')}
/>
</SimpleGrid>
</div>
@@ -724,6 +745,7 @@ export function ProfilePage() {
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
isSeafarer={isSeafarer}
/>
</div>

View File

@@ -22,12 +22,12 @@ import {
IconCheck,
IconLock,
IconMail,
IconPhone,
IconShip,
IconUser,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
const OWNER_TYPES = [
'Individual (Private Owner)',
@@ -51,7 +51,7 @@ export function VesselOwnerRegisterPage() {
const [success, setSuccess] = useState(false);
const [registerTrigger] = useApiMutation<{ id: string }>();
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
const canSubmit = !!fullName.trim() && !!email.trim() && isValidPhoneNumber(phone) && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
const handleRegister = async () => {
if (!canSubmit) {
@@ -143,13 +143,11 @@ export function VesselOwnerRegisterPage() {
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<TextInput
<PhoneInput
label="Phone Number"
placeholder="+251 9XX XXX XXX"
leftSection={<IconPhone size={16} />}
required
value={phone}
onChange={(e) => setPhone(e.currentTarget.value)}
onChange={setPhone}
/>
<TextInput
label="National ID / TIN"

View File

@@ -31,7 +31,8 @@ import {
IconTransferIn,
IconUser,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
// Minimal vessel type for the approved vessel list
interface ApprovedVessel {
@@ -208,7 +209,7 @@ export function OwnershipTransferPage() {
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
isValidPhoneNumber(newOwnerPhone) && !!transferReason && !!billOfSale;
const resetForm = () => {
setSelectedVesselId(null);
@@ -393,7 +394,7 @@ export function OwnershipTransferPage() {
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" required value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} />
<PhoneInput label="Phone Number" required value={newOwnerPhone} onChange={setNewOwnerPhone} />
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />

View File

@@ -35,7 +35,8 @@ import {
IconShip,
IconWaveSine,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
// ---------------------------------------------------------------------------
// Constants
@@ -311,7 +312,7 @@ export function VesselRegistrationApplicationPage() {
if (active === 2) return (
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && isValidPhoneNumber(ownerPhone)
);
if (active === 3) return category === 'Inland Waterway Vessel'
? !!files.vesselPhotos
@@ -551,12 +552,11 @@ export function VesselRegistrationApplicationPage() {
value={ownerNationalIdOrTin}
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
/>
<TextInput
<PhoneInput
label="Owner Phone"
placeholder="+251 9XX XXX XXX"
required
value={ownerPhone}
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
onChange={setOwnerPhone}
/>
<TextInput
label="Owner Address"

View File

@@ -258,6 +258,10 @@ export const am: Translations = {
dob: 'የትውልድ ቀን',
pob: 'የትውልድ ቦታ',
maritalStatus: 'የጋብቻ ሁኔታ',
bloodType: 'የደም አይነት',
hairColor: 'የፀጉር ቀለም',
eyeColor: 'የአይን ቀለም',
heightCm: 'ቁመት (ሴ.ሜ)',
professionId: 'ሙያ',
idType: 'የመታወቂያ ዓይነት',
idNumber: 'የመታወቂያ ቁጥር',
@@ -436,6 +440,7 @@ export const am: Translations = {
middleName: 'የአባት ስም ያስገቡ',
lastName: 'የአያት ስም ያስገቡ',
pob: 'ከተማ፣ ክልል',
heightCm: 'ለምሳሌ 175',
},
genders: {
MALE: 'ወንድ',
@@ -447,6 +452,36 @@ export const am: Translations = {
DIVORCED: 'የፈታ/ች',
WIDOWED: 'የሞተበት/ባት',
},
bloodTypes: {
A_POSITIVE: 'A+',
A_NEGATIVE: 'A-',
B_POSITIVE: 'B+',
B_NEGATIVE: 'B-',
AB_POSITIVE: 'AB+',
AB_NEGATIVE: 'AB-',
O_POSITIVE: 'O+',
O_NEGATIVE: 'O-',
UNKNOWN: 'የማይታወቅ',
},
hairColors: {
BLACK: 'ጥቁር',
BROWN: 'ቡናማ',
BLONDE: 'ወርቃማ',
RED: 'ቀይ',
GREY: 'ግራጫ',
WHITE: 'ነጭ',
BALD: 'ራሰ በራ',
OTHER: 'ሌላ',
},
eyeColors: {
BROWN: 'ቡናማ',
BLACK: 'ጥቁር',
BLUE: 'ሰማያዊ',
GREEN: 'አረንጓዴ',
HAZEL: 'ኮክ ቡናማ',
GREY: 'ግራጫ',
OTHER: 'ሌላ',
},
validation: {
professionRequired: 'ሙያዎን ይምረጡ',
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
@@ -457,6 +492,12 @@ export const am: Translations = {
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
heightRange: 'ቁመት ከ100 እስከ 250 ሴ.ሜ መሆን አለበት',
pobRequired: 'የትውልድ ቦታዎን ያስገቡ',
bloodTypeRequired: 'የደም አይነትዎን ይምረጡ — ካልተመረመሩ የማይታወቅ ይምረጡ',
hairColorRequired: 'የፀጉር ቀለምዎን ይምረጡ',
eyeColorRequired: 'የአይን ቀለምዎን ይምረጡ',
heightRequired: 'ቁመትዎን ያስገቡ',
},
},
@@ -542,7 +583,7 @@ export const am: Translations = {
},
login: {
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ",
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
subtitle: "መለያዎን ለመድረስ ይግቡ።",
@@ -577,7 +618,7 @@ export const am: Translations = {
usernameLabel: "የተጠቃሚ ስም",
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
phoneLabel: "ስልክ ቁጥር",
phonePlaceholder: "+251 911 234 567",
phonePlaceholder: "9XX XXX XXX",
passwordLabel: "የይለፍ ቃል",
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
@@ -692,7 +733,7 @@ export const am: Translations = {
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
idTypePlaceholder: 'ይምረጡ',
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
phonePlaceholder: '+251 9XX XXX XXX',
phonePlaceholder: '9XX XXX XXX',
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
postalAddressPlaceholder: 'ፖስታ ሳጥን',
contactNamePlaceholder: 'ሙሉ ስም',

View File

@@ -257,6 +257,10 @@ export const en = {
dob: 'Date of birth',
pob: 'Place of birth',
maritalStatus: 'Marital status',
bloodType: 'Blood type',
hairColor: 'Hair color',
eyeColor: 'Eye color',
heightCm: 'Height (cm)',
professionId: 'Profession',
idType: 'ID type',
idNumber: 'ID number',
@@ -435,6 +439,7 @@ export const en = {
middleName: 'Enter middle name',
lastName: 'Enter last name',
pob: 'City, Region',
heightCm: 'e.g. 175',
},
genders: {
MALE: 'Male',
@@ -446,6 +451,36 @@ export const en = {
DIVORCED: 'Divorced',
WIDOWED: 'Widowed',
},
bloodTypes: {
A_POSITIVE: 'A+',
A_NEGATIVE: 'A-',
B_POSITIVE: 'B+',
B_NEGATIVE: 'B-',
AB_POSITIVE: 'AB+',
AB_NEGATIVE: 'AB-',
O_POSITIVE: 'O+',
O_NEGATIVE: 'O-',
UNKNOWN: 'Unknown',
},
hairColors: {
BLACK: 'Black',
BROWN: 'Brown',
BLONDE: 'Blonde',
RED: 'Red',
GREY: 'Grey',
WHITE: 'White',
BALD: 'Bald',
OTHER: 'Other',
},
eyeColors: {
BROWN: 'Brown',
BLACK: 'Black',
BLUE: 'Blue',
GREEN: 'Green',
HAZEL: 'Hazel',
GREY: 'Grey',
OTHER: 'Other',
},
validation: {
professionRequired: 'Select your profession',
firstNameMin: 'First name must be at least 3 characters',
@@ -456,6 +491,12 @@ export const en = {
dobMinAge: 'You must be at least 18 years old',
maritalStatusRequired: 'Select your marital status',
nameParts: 'Enter your first, middle, and last name',
heightRange: 'Height must be between 100 and 250 cm',
pobRequired: 'Enter your place of birth',
bloodTypeRequired: 'Select your blood type — choose Unknown if untested',
hairColorRequired: 'Select your hair color',
eyeColorRequired: 'Select your eye color',
heightRequired: 'Enter your height',
},
},
@@ -541,7 +582,7 @@ export const en = {
},
login: {
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
emailOrPhoneInvalid: 'Enter a valid email or phone number',
passwordMinLength: 'Password must be at least 8 characters',
welcome: 'Welcome to {{appName}}',
subtitle: 'Sign in to access your account.',
@@ -576,7 +617,7 @@ export const en = {
usernameLabel: 'Username',
usernamePlaceholder: 'Choose a username',
phoneLabel: 'Phone number',
phonePlaceholder: '+251 911 234 567',
phonePlaceholder: '9XX XXX XXX',
passwordLabel: 'Password',
passwordPlaceholder: 'At least 8 characters',
confirmPasswordLabel: 'Confirm password',
@@ -691,7 +732,7 @@ export const en = {
accountManagedHint: 'From your account, edit it in the Personal tab',
idTypePlaceholder: 'Select',
idNumberPlaceholder: 'Enter ID number',
phonePlaceholder: '+251 9XX XXX XXX',
phonePlaceholder: '9XX XXX XXX',
streetAddressPlaceholder: 'Street name, house number',
postalAddressPlaceholder: 'P.O. Box',
contactNamePlaceholder: 'Full name',

View File

@@ -39,7 +39,6 @@ import { NotificationsPage } from "./features/notifications/pages/NotificationsP
// Phase 2 — CoC / CoP
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
// Phase 3 — Endorsement
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
@@ -69,6 +68,10 @@ export const router = createBrowserRouter([
{ path: "/set-password", element: <SetPasswordPage /> },
{ path: "/reset-password", element: <SetPasswordPage /> },
// Reached from the login page by a logged-out user, so it must stay
// public — ProtectedRoute would bounce them straight to the landing page.
{ path: "/forgot-password", element: <ForgotPasswordPage /> },
// Protected auth pages
{
element: (
@@ -78,14 +81,6 @@ export const router = createBrowserRouter([
),
path: "/otp-verify",
},
{
element: (
<ProtectedRoute>
<ForgotPasswordPage />
</ProtectedRoute>
),
path: "/forgot-password",
},
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
// profile details are collected where they are actually needed: on /profile,
// via the dashboard nudge, or inline in an application flow. The path stays
@@ -275,14 +270,9 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: "/certificates/apply",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<CoCApplicationPage />
</RequirePermission>
),
},
// CoC/CoP applications go through the generic license wizard —
// /licensing/CERTIFICATE_OF_COMPETENCY/apply and
// /licensing/CERTIFICATE_OF_PROFICIENCY/apply, wired below.
// Phase 3 — Endorsement
{

View File

@@ -337,7 +337,7 @@ export const mockApplications: Record<string, any> = {
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
applicantUserId: 'user-mock-001',
kind: 'RENEWAL',
status: 'ELIGIBILITY_APPROVED',
status: 'ELIGIBILITY_PAID',
assignedOfficerId: 'officer-mock-002',
claimedAt: '2026-08-01T10:00:00.000Z',
formData: { account: { applicantName: 'Abebe Tesfaye' } },

View File

@@ -6,6 +6,9 @@ import type {
ApplicationPayment,
ApplicationStaff,
Attachment,
DocumentRequirement,
FormSchemaPalette,
FormSectionConfig,
InitiatePaymentResult,
IssuedLicense,
Inspection,
@@ -25,6 +28,7 @@ import type {
QueueFilter,
RemarkTargetType,
SavedQueueView,
SchemaIssue,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
@@ -66,6 +70,7 @@ const TAGS = [
'License',
'SavedView',
'LicenseTemplate',
'DocumentRequirement',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -178,6 +183,76 @@ export const licensingApi = baseApi
providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)],
}),
// ------------------------------------------------ form-schema builder
/** Replaces a licence type's form schema. Server re-validates on save. */
updateFormSchema: builder.mutation<
LicenseType,
{ id: string; formSchema: { sections: FormSectionConfig[] } }
>({
query: ({ id, formSchema }) => ({
url: `/license-types/${id}/form-schema`,
method: 'PUT',
body: { formSchema },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
}),
/** Dry-run lint, for inline feedback while the schema is being edited. */
validateFormSchema: builder.mutation<
{ valid: boolean; issues: SchemaIssue[] },
{ formSchema: { sections: FormSectionConfig[] }; licenseTypeId?: string }
>({
query: (body) => ({
url: '/license-types/form-schema/validate',
method: 'POST',
body,
}),
}),
/** Field types, condition operators and prefill sources the builder may offer. */
getFormSchemaPalette: builder.query<FormSchemaPalette, void>({
query: () => ({ url: '/license-types/form-schema/palette' }),
}),
// ------------------------------------------------- document requirements
/**
* Every document requirement, for the admin editor to filter by licence
* type client-side. The collection-query `q` filter syntax (`w=column:op:
* value`) has no typed builder on this side, and the table is small
* configuration data with no pagination need — see `licenseTypeId` usage
* at the call site.
*/
getDocumentRequirements: builder.query<Paginated<DocumentRequirement>, void>({
query: () => ({ url: '/document-requirements' }),
providesTags: () => [listTag('DocumentRequirement')],
}),
createDocumentRequirement: builder.mutation<
DocumentRequirement,
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
>({
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
updateDocumentRequirement: builder.mutation<
DocumentRequirement,
{ id: string } & Partial<DocumentRequirement>
>({
query: ({ id, ...body }) => ({
url: `/document-requirements/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
deleteDocumentRequirement: builder.mutation<unknown, string>({
query: (id) => ({ url: `/document-requirements/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
// -------------------------------------------------------- application
createApplication: builder.mutation<
LicenseApplication,
@@ -494,9 +569,26 @@ export const licensingApi = baseApi
scheduleExam: builder.mutation<
LicenseApplication,
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
>({
query: ({ id, examDate: _examDate, ...body }) => ({
// Matches the controller's `:id/exam-scheduled` route — `examDate`
// is UI-only context for the confirmation toast, not part of
// `MarkExamScheduledDto`, so it never goes on the wire.
url: `/license-application-review/${id}/exam-scheduled`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
/** Records a published examination result (pass or fail). */
recordExamOutcome: builder.mutation<
LicenseApplication,
{ id: string; passed: boolean; score?: number }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/schedule-exam`,
url: `/license-application-review/${id}/exam-outcome`,
method: 'POST',
body,
}),
@@ -505,12 +597,13 @@ export const licensingApi = baseApi
}),
/**
* Raises the examination fee — after eligibility approval, or again when
* a failed candidate elects to resit.
* A failed candidate asks for another sitting. Re-opens the examination
* fee (EXAM_FAILED -> EXAM_PAYMENT_PENDING); eligibility was already
* assessed and paid for on the first attempt.
*/
requestExamPayment: builder.mutation<LicenseApplication, string>({
retakeExam: builder.mutation<LicenseApplication, string>({
query: (id) => ({
url: `/license-applications/${id}/request-exam-payment`,
url: `/license-applications/${id}/retake`,
method: 'POST',
}),
invalidatesTags: (_r, error, id) =>
@@ -809,6 +902,13 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
useGetFormSchemaPaletteQuery,
useGetDocumentRequirementsQuery,
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
@@ -864,7 +964,8 @@ export const {
useFinalApproveMutation,
useRejectApplicationMutation,
useScheduleExamMutation,
useRequestExamPaymentMutation,
useRecordExamOutcomeMutation,
useRetakeExamMutation,
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,

View File

@@ -1,3 +1,4 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
@@ -74,7 +75,8 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
SCHEDULED: 'Pickup Scheduled',
CERTIFICATE_ISSUED: 'Certificate Issued',
COMPLETED: 'Completed',
ELIGIBILITY_APPROVED: 'Eligible to Sit',
ELIGIBILITY_PAYMENT_PENDING: 'Eligibility Fee Due',
ELIGIBILITY_PAID: 'Eligibility Under Review',
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
EXAM_PAID: 'Awaiting Exam Date',
EXAM_SCHEDULED: 'Exam Scheduled',
@@ -100,7 +102,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
SCHEDULED: 'cyan',
CERTIFICATE_ISSUED: 'green',
COMPLETED: 'green',
ELIGIBILITY_APPROVED: 'teal',
ELIGIBILITY_PAYMENT_PENDING: 'yellow',
ELIGIBILITY_PAID: 'lime',
EXAM_PAYMENT_PENDING: 'yellow',
EXAM_PAID: 'lime',
EXAM_SCHEDULED: 'cyan',
@@ -135,7 +138,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
REJECTED: 100,
// The exam leg sits between approval and the certificate fee, so these
// interleave with PAYMENT_PENDING (80) rather than running past it.
ELIGIBILITY_APPROVED: 60,
ELIGIBILITY_PAYMENT_PENDING: 52,
ELIGIBILITY_PAID: 56,
EXAM_PAYMENT_PENDING: 64,
EXAM_PAID: 68,
EXAM_SCHEDULED: 72,
@@ -149,10 +153,15 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
'DRAFT',
'RESUBMIT_REQUIRED',
'PAYMENT_PENDING',
// Due the moment an examined application is submitted, before any officer
// looks at it.
'ELIGIBILITY_PAYMENT_PENDING',
// Both wait on the candidate: one to pay for a sitting, one to decide to
// sit again after a failure.
'EXAM_PAYMENT_PENDING',
'EXAM_FAILED',
// Passed: the certificate fee falls due, and only the candidate can pay it.
'EXAM_PASSED',
];
/** Statuses that are finished, whichever way they went. */
@@ -526,6 +535,13 @@ export function validateSections(
}
if (empty) continue;
// PhoneInput emits E.164 while typing, so a half-typed "+2519" is a
// non-empty string that still has to be caught here.
if (field.type === 'PHONE' && !isValidPhoneNumber(String(value))) {
errors[`${section.key}.${field.key}`] = 'Enter a valid phone number';
continue;
}
const numeric = Number(value);
if (!Number.isNaN(numeric)) {
if (field.min !== undefined && numeric < field.min) {

View File

@@ -41,9 +41,11 @@ export type LicenseStatus =
| "SCHEDULED"
| "CERTIFICATE_ISSUED"
| "COMPLETED"
// Examined certificates (CoC, some CoP): approval establishes eligibility,
// the candidate pays to sit, and the certificate fee falls due on a pass.
| "ELIGIBILITY_APPROVED"
// Examined certificates (CoC, some CoP): the eligibility assessment fee is
// due before review starts, then the candidate pays to sit, and the
// certificate fee falls due on a pass.
| "ELIGIBILITY_PAYMENT_PENDING"
| "ELIGIBILITY_PAID"
| "EXAM_PAYMENT_PENDING"
| "EXAM_PAID"
| "EXAM_SCHEDULED"
@@ -77,10 +79,12 @@ export interface FormFieldConfig {
label: Bilingual;
type: FormFieldType;
required?: boolean;
placeholder?: Bilingual;
helpText?: Bilingual;
options?: { value: string; label: Bilingual }[];
min?: number;
max?: number;
maxLength?: number;
showWhen?: FieldCondition;
readOnly?: boolean;
source?: string;
@@ -233,6 +237,7 @@ export interface StcwCapacityRow {
export interface DocumentRequirement {
id: string;
licenseTypeId: string;
key: string;
name: Bilingual;
description?: Bilingual;
@@ -242,7 +247,32 @@ export interface DocumentRequirement {
allowedMimeTypes: string[];
maxSizeMb: number;
requiresValidityDates: boolean;
allowMultiple: boolean;
sortOrder: number;
isActive: boolean;
}
/**
* What the form-schema builder may put on a form — the field types the engine
* understands, which constraints each one honours, the condition operators it
* supports, and the prefill sources a read-only field may draw from. Drives
* the builder's pickers so they never hardcode a list the server already owns.
*/
export interface FormSchemaPalette {
fieldTypes: {
type: FormFieldType;
supportsOptions: boolean;
supportsRange: boolean;
supportsMaxLength: boolean;
}[];
conditionOperators: ("equals" | "notEquals" | "in" | "isSet")[];
prefillSources: string[];
}
/** One problem the server's form-schema lint found. */
export interface SchemaIssue {
path: string;
message: string;
}
export interface StaffEvidenceRequirement {

View File

@@ -23,11 +23,25 @@ import { z } from 'zod';
import { Link } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
// Same email-or-phone rule as LoginPage: a phone-looking value normalizes to
// E.164 (bare Ethiopian national numbers default to +251) so the backend
// always gets a value it can look the account up by, under the `email` key.
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
email: z
.string()
.trim()
.transform((value) => {
if (emailRegex.test(value)) return value;
return parsePhoneNumberFromString(value, 'ET')?.number ?? value;
})
.refine((value) => emailRegex.test(value) || isValidPhoneNumber(value), {
message: 'Enter a valid email or phone number',
}),
});
type FormValues = z.infer<typeof schema>;
@@ -156,8 +170,8 @@ export function ForgotPasswordPage() {
Forgot your password?
</Title>
<Text c="dimmed" mt={6}>
Enter the email linked to your account and we&apos;ll send you a link
to reset your password.
Enter the email or phone number linked to your account and
we&apos;ll send you a link to reset your password.
</Text>
</div>
@@ -170,7 +184,7 @@ export function ForgotPasswordPage() {
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email address"
label="Email or phone"
placeholder="you@example.com"
size="md"
leftSection={<IconMail size={18} />}

View File

@@ -28,6 +28,7 @@ import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useApiMutation } from "@ema-platform/api";
import { notify, useErrorHandler } from "@ema-platform/ui";
import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
import { AuthShell } from "../components/AuthShell";
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
import type {
@@ -53,11 +54,7 @@ export function LoginPage() {
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/");
}
navigate("/");
};
// Built inside the component (not module scope) so validation messages
@@ -67,24 +64,23 @@ export function LoginPage() {
.string()
.trim()
.transform((value) => {
// Convert 09xxxxxxxx -> +2519xxxxxxxx
if (/^09\d{8}$/.test(value)) {
return `+251${value.substring(1)}`;
}
return value;
// A phone-looking value normalizes to E.164 (bare Ethiopian
// national numbers, e.g. 09xxxxxxxx, default to +251) so the
// international check below can validate it; anything else
// (an email) passes through untouched.
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(value)) return value;
return parsePhoneNumberFromString(value, "ET")?.number ?? value;
})
.refine(
(value) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^\+2519\d{8}$/;
return emailRegex.test(value) || phoneRegex.test(value);
return emailRegex.test(value) || isValidPhoneNumber(value);
},
{
message: t(
"login.emailOrPhoneInvalid",
"Enter a valid email or phone number (+2519xxxxxxxx)",
"Enter a valid email or phone number",
),
},
),

View File

@@ -17,7 +17,6 @@ import {
IconArrowLeft,
IconArrowRight,
IconAt,
IconDeviceMobile,
IconLock,
IconMail,
IconUser,
@@ -29,7 +28,7 @@ import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useApiMutation } from '@ema-platform/api';
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
import { useErrorHandler, passwordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
@@ -88,7 +87,7 @@ export function SignupPage() {
.object({
email: z.string().email(),
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
phoneNumber,
userType: z.literal('individual'),
nameEn: z
.string()
@@ -111,6 +110,8 @@ export function SignupPage() {
register,
handleSubmit,
watch,
setValue,
trigger,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -247,12 +248,13 @@ export function SignupPage() {
/>
</SimpleGrid>
<TextInput
<PhoneInput
label={t('signup.phoneLabel', 'Phone number')}
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
leftSection={<IconDeviceMobile size={18} />}
placeholder={t('signup.phonePlaceholder', '9XX XXX XXX')}
value={watch('phoneNumber') || ''}
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
onBlur={() => trigger('phoneNumber')}
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">

View File

@@ -87,6 +87,11 @@ export interface CurrentProfile {
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
seafarerStatusReason?: string | null;
/** Identifying particulars for the Seaman Book. Left blank by choice. */
bloodType?: string | null;
hairColor?: string | null;
eyeColor?: string | null;
heightCm?: number | null;
user: AuthUser;
address: CurrentProfileAddress;
profession: CurrentProfileProfession;

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { currentSessionId } from './jwt';
/** Builds a JWT-shaped string whose payload is `claims`, base64url encoded. */
function token(claims: Record<string, unknown>): string {
const payload = btoa(JSON.stringify(claims))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `header.${payload}.signature`;
}
describe('currentSessionId', () => {
it('reads the sessionId claim', () => {
expect(currentSessionId(token({ sessionId: 'abc' }))).toBe('abc');
});
it('falls back to sid, then jti', () => {
expect(currentSessionId(token({ sid: 'from-sid' }))).toBe('from-sid');
expect(currentSessionId(token({ jti: 'from-jti' }))).toBe('from-jti');
});
it('decodes payloads containing base64url characters', () => {
// '>' and '?' are what force '+' and '/' in standard base64.
const id = 'a>b?c>d?e>f?';
expect(currentSessionId(token({ sessionId: id }))).toBe(id);
});
it('returns undefined for a token with no session claim', () => {
expect(currentSessionId(token({ sub: 'user-1' }))).toBeUndefined();
});
it('returns undefined rather than throwing on junk', () => {
expect(currentSessionId(undefined)).toBeUndefined();
expect(currentSessionId('')).toBeUndefined();
expect(currentSessionId('opaque-token')).toBeUndefined();
expect(currentSessionId('header.not-base64!!.sig')).toBeUndefined();
});
});

View File

@@ -21,6 +21,7 @@ export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/feedback/use-error-handler";

View File

@@ -16,9 +16,9 @@ registerLocale(en);
registerLocale(am);
registerNationalityLocale(nationalityEn);
type CountryLang = 'en' | 'am';
export type CountryLang = 'en' | 'am';
function resolveLang(lng: string): CountryLang {
export function resolveLang(lng: string): CountryLang {
return lng === 'am' ? 'am' : 'en';
}
@@ -44,7 +44,7 @@ export function getNationalityName(code: string | null | undefined, lang: Countr
return getCountryName(code, lang);
}
function CountryFlag({ code }: { code: string }) {
export function CountryFlag({ code }: { code: string }) {
const Flag = Flags[code as keyof typeof Flags];
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
}

View File

@@ -0,0 +1,219 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core';
import {
getCountries,
getCountryCallingCode,
parsePhoneNumberFromString,
type CountryCode,
} from 'libphonenumber-js';
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, toE164, toNationalDigits } from './phone';
// Static list, computed once at module load — same as CountrySelect's dataset.
const COUNTRY_CODES = getCountries();
type CountryOption = ComboboxItem & { name: string };
// Search by country name ("united"), dial code ("+1") or ISO prefix ("us") —
// the closed control's label alone ("+1") isn't enough to find a country.
const filterCountries: SelectProps['filter'] = ({ options, search }) => {
const q = search.trim().toLowerCase();
if (!q) return options;
return (options as CountryOption[]).filter(
(o) =>
o.name.toLowerCase().includes(q) ||
o.label.toLowerCase().includes(q) ||
o.value.toLowerCase().startsWith(q),
);
};
const renderCountryOption: SelectProps['renderOption'] = ({ option }) => {
const o = option as CountryOption;
return (
<Group gap="xs" wrap="nowrap" justify="space-between" flex={1}>
<Group gap="xs" wrap="nowrap">
<CountryFlag code={o.value} />
<Text fz="sm">{o.name}</Text>
</Group>
<Text fz="xs" c="dimmed">{o.label}</Text>
</Group>
);
};
export interface PhoneInputProps {
/** E.164 (`+14155552671`), or '' when empty. */
value: string;
onChange: (value: string) => void;
/**
* Fired when focus leaves a field that has digits in it — wire to the
* form's `trigger`. A blank field is left to submit-time validation, like
* the form's other inputs, so tabbing past it doesn't raise an error.
*/
onBlur?: () => void;
label?: React.ReactNode;
placeholder?: string;
description?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
/** Mantine's asterisk-without-`required` variant, as used by config-driven forms. */
withAsterisk?: boolean;
disabled?: boolean;
readOnly?: boolean;
}
/**
* International phone entry: a searchable country/dial-code select beside a
* national-number text box, WhatsApp/Telegram style. Controlled —
* `value`/`onChange` carry the E.164 string.
*
* The typed digits live in local state rather than being re-derived from
* `value` on every render: an incomplete number doesn't parse, so deriving
* would blank the box between keystrokes.
*/
export function PhoneInput({
value,
onChange,
onBlur,
label,
placeholder,
description,
error,
required,
withAsterisk,
disabled,
readOnly,
}: PhoneInputProps) {
const { t, i18n } = useTranslation();
const lang = resolveLang(i18n.language);
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
const [country, setCountry] = useState<CountryCode>(initialCountry);
const [national, setNational] = useState(() =>
formatNational(toNationalDigits(value, initialCountry), initialCountry),
);
// Mantine keeps the selected label ("+251") as the search text, so typing
// would search for "+251u". Cleared while the dropdown is open instead.
const [search, setSearch] = useState('');
// What this field last pushed upward, so an echo of our own value isn't
// mistaken for the parent resetting the form.
const emitted = useRef(value);
useEffect(() => {
if (value === emitted.current) return;
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
const next = parsedCountry ?? country;
if (parsedCountry) setCountry(parsedCountry);
setNational(formatNational(toNationalDigits(value, next), next));
emitted.current = value;
// Adopting an outside change only — `country` is state written here, so
// re-running on it would fight the user's typing.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const countryOptions = useMemo<CountryOption[]>(
() =>
COUNTRY_CODES.map((code) => ({
value: code,
label: `+${getCountryCallingCode(code)}`,
name: getCountryName(code, lang),
})).sort((a, b) => a.name.localeCompare(b.name, lang)),
[lang],
);
function push(next: string) {
emitted.current = next;
onChange(next);
}
function applyDigits(digits: string, forCountry: CountryCode) {
// Keystroke past the longest possible number is ignored, not stored.
if (exceedsMaxLength(digits, forCountry)) return;
// Once the number is valid, show the true national part: someone who
// types a trunk prefix (0911111111) or the country code (251911111111)
// shouldn't end up with it doubled beside the "+251" selector. Not
// before: a partial number can parse too, and rewriting it mid-typing
// makes digits vanish under the caret.
const parsed = parsePhoneNumberFromString(digits, forCountry);
setNational(formatNational(parsed?.isValid() ? parsed.nationalNumber : digits, forCountry));
push(toE164(digits, forCountry));
}
function handleText(text: string) {
// A full `+<code><number>` arriving at once (paste, autofill, or a test
// driver's `.fill()`) is parsed standalone and switches the country,
// rather than being read under whatever country was already selected.
const trimmed = text.trim();
if (trimmed.startsWith('+')) {
const parsed = parsePhoneNumberFromString(trimmed);
if (parsed?.country) {
setCountry(parsed.country);
setNational(formatNational(parsed.nationalNumber, parsed.country));
push(parsed.number);
return;
}
}
applyDigits(nextNationalDigits(text, national), country);
}
function handleCountryChange(next: string | null) {
if (!next) return;
const code = next as CountryCode;
setCountry(code);
// A number carried over from a longer plan is cut to fit the new one.
applyDigits(national.replace(/\D/g, '').slice(0, maxNationalLength(code)), code);
}
return (
<TextInput
type="tel"
inputMode="tel"
autoComplete="tel"
label={label}
description={description}
error={error}
required={required}
withAsterisk={withAsterisk}
disabled={disabled}
readOnly={readOnly}
placeholder={placeholder}
value={national}
onChange={(e) => handleText(e.currentTarget.value)}
// Blur of the whole field, not of the number box: moving into the
// country select mustn't validate a half-typed number.
wrapperProps={{
onBlur: (e: React.FocusEvent<HTMLDivElement>) => {
if (national && !e.currentTarget.contains(e.relatedTarget)) onBlur?.();
},
}}
leftSectionWidth={92}
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
leftSection={
<Select
aria-label={t('phone.countryCode', 'Country code')}
data={countryOptions}
value={country}
onChange={handleCountryChange}
renderOption={renderCountryOption}
filter={filterCountries}
searchable
searchValue={search}
onSearchChange={setSearch}
onDropdownOpen={() => setSearch('')}
onDropdownClose={() => setSearch(`+${getCountryCallingCode(country)}`)}
nothingFoundMessage={t('phone.noCountry', 'No matching country')}
allowDeselect={false}
disabled={disabled || readOnly}
variant="unstyled"
size="xs"
w={92}
maxDropdownHeight={320}
comboboxProps={{ width: 260, position: 'bottom-start' }}
leftSection={<CountryFlag code={country} />}
leftSectionWidth={30}
/>
}
/>
);
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
describe('phoneNumber', () => {
it('normalizes a legacy Ethiopian national number to E.164', () => {
expect(phoneNumber.parse('0911223344')).toBe('+251911223344');
});
it('passes an Ethiopian E.164 number through unchanged', () => {
expect(phoneNumber.parse('+251911223344')).toBe('+251911223344');
});
it('accepts a valid international number', () => {
expect(phoneNumber.parse('+14155552671')).toBe('+14155552671');
});
it('rejects a too-short number', () => {
expect(() => phoneNumber.parse('+251911')).toThrow();
});
it('rejects non-numeric input', () => {
expect(() => phoneNumber.parse('abc')).toThrow('Enter a valid phone number');
});
it('reports a blank value as missing, not invalid', () => {
expect(() => phoneNumber.parse('')).toThrow('Phone number is required');
});
});
describe('optionalPhoneNumber', () => {
it('allows a blank value', () => {
expect(optionalPhoneNumber.parse('')).toBe('');
});
it('still validates a non-blank value', () => {
expect(() => optionalPhoneNumber.parse('abc')).toThrow();
});
});
describe('typing helpers', () => {
// Regression: an incomplete number doesn't parse, and an earlier version
// collapsed it to '' — the box emptied on every keystroke.
it('keeps partial digits as the number is typed one character at a time', () => {
let display = '';
let value = '';
for (const ch of '911223344') {
const digits = nextNationalDigits(display + ch, display);
display = new AsYouType('ET').input(digits);
value = toE164(digits, 'ET');
}
expect(display).toBe('911223344');
expect(value).toBe('+251911223344');
});
it('drops a digit when a keystroke only removed a formatting character', () => {
// "(415)" backspaced to "(415" leaves the digits unchanged.
expect(nextNationalDigits('(415', '(415)')).toBe('41');
});
it('keeps the deleted digit count when a real digit is removed', () => {
expect(nextNationalDigits('91122334', '911223344')).toBe('91122334');
});
it('reads the national part back out of a stored E.164 value', () => {
expect(toNationalDigits('+251911223344', 'ET')).toBe('911223344');
expect(toNationalDigits('+14155552671', 'US')).toBe('4155552671');
expect(toNationalDigits('', 'ET')).toBe('');
});
it('falls back to a dial-code concatenation while the number is incomplete', () => {
expect(toE164('9', 'ET')).toBe('+2519');
expect(toE164('', 'ET')).toBe('');
});
});
describe('trunk prefix and country code entered into the number box', () => {
// The box holds the national part next to a "+251" selector, so a trunk 0
// or a typed country code must be absorbed, not shown (and doubled) there.
const cases: Array<[string, string]> = [
['0911111111', '911111111'],
['251911111111', '911111111'],
['911111111', '911111111'],
];
it.each(cases)('normalizes %s to the national number %s', (typed, national) => {
const parsed = parsePhoneNumberFromString(typed.replace(/\D/g, ''), 'ET');
expect(parsed?.nationalNumber).toBe(national);
});
it.each(cases)('yields a valid E.164 value for %s', (typed) => {
expect(phoneNumber.parse(typed)).toBe('+251911111111');
});
it('accepts a pasted +251 number', () => {
expect(phoneNumber.parse('+251911666666')).toBe('+251911666666');
});
});
describe('formatNational', () => {
it('groups digits without the dial code or trunk prefix', () => {
expect(formatNational('911223344', 'ET')).toBe('91 122 3344');
expect(formatNational('4155552671', 'US')).toBe('415 555 2671');
expect(formatNational('91', 'ET')).toBe('91');
expect(formatNational('', 'ET')).toBe('');
});
it('keeps digits intact when the number does not format', () => {
expect(formatNational('0911223344', 'ET').replace(/\D/g, '')).toBe('0911223344');
});
});
describe('legacy stored values', () => {
it('shows a national record without the trunk prefix', () => {
expect(toNationalDigits('0911223344', 'ET')).toBe('911223344');
});
});
describe('length limit', () => {
it('reports the longest national number per country', () => {
expect(maxNationalLength('ET')).toBe(9);
expect(maxNationalLength('US')).toBe(10);
});
it('flags digits past the limit, counting the national part only', () => {
expect(exceedsMaxLength('911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('9112233445', 'ET')).toBe(true);
expect(exceedsMaxLength('0911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('251911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('09112233445', 'ET')).toBe(true);
});
});

View File

@@ -1,13 +1,88 @@
import { z } from 'zod';
import { AsYouType, Metadata, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
/** Accepts `09xxxxxxxx` or `+2519xxxxxxxx`; always yields `+2519xxxxxxxx`. */
export const ethiopianPhone = z
/**
* Accepts any international number in E.164 (`+<country><number>`) or a
* bare national number, which is assumed Ethiopian (`0911223344` ->
* `+251911223344`) for backward compatibility with existing records.
*/
export const phoneNumber = z
.string()
.trim()
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
.refine((v) => /^\+2519\d{8}$/.test(v), {
message: 'Enter a valid phone number (+2519xxxxxxxx)',
.transform((v) => parsePhoneNumberFromString(v, 'ET')?.number ?? v)
.superRefine((v, ctx) => {
if (!v) ctx.addIssue({ code: 'custom', message: 'Phone number is required' });
else if (!isValidPhoneNumber(v)) ctx.addIssue({ code: 'custom', message: 'Enter a valid phone number' });
});
/** Same rules, but blank is allowed. */
export const optionalEthiopianPhone = z.union([z.literal(''), ethiopianPhone]).optional();
export const optionalPhoneNumber = z.union([z.literal(''), phoneNumber]).optional();
/**
* Digits the field should hold after an edit. A keystroke that only removed
* a formatting character (backspacing the ')' out of "(415)") leaves the
* digits unchanged, which would otherwise make the caret stick — drop a real
* digit in that case.
*/
export function nextNationalDigits(text: string, prevDisplay: string): string {
const digits = text.replace(/\D/g, '');
const deleting = text.length < prevDisplay.length;
if (deleting && digits === prevDisplay.replace(/\D/g, '')) return digits.slice(0, -1);
return digits;
}
/** National digits of a stored value, for display in the number box. */
export function toNationalDigits(value: string, country: CountryCode): string {
if (!value) return '';
// `country` also resolves legacy national records ("0911223344").
const parsed = parsePhoneNumberFromString(value, country);
if (parsed) return parsed.nationalNumber;
if (value.startsWith('+')) {
const prefix = `+${getCountryCallingCode(country)}`;
if (value.startsWith(prefix)) return value.slice(prefix.length).replace(/\D/g, '');
}
return value.replace(/\D/g, '');
}
/**
* E.164 for the digits typed so far. Incomplete numbers don't parse, so they
* fall back to a plain dial-code concatenation rather than collapsing to ''
* — the value has to survive mid-typing for the field to be usable.
*/
export function toE164(digits: string, country: CountryCode): string {
if (!digits) return '';
return (
parsePhoneNumberFromString(digits, country)?.number ??
`+${getCountryCallingCode(country)}${digits}`
);
}
/**
* Digits grouped for display ("91 122 3344"). Formatted as an international
* number with the dial code cut off: AsYouType's national mode leaves the
* number ungrouped unless the trunk prefix was typed.
*/
export function formatNational(digits: string, country: CountryCode): string {
if (!digits) return '';
const prefix = `+${getCountryCallingCode(country)}`;
const formatted = new AsYouType().input(prefix + digits);
return formatted.startsWith(prefix) ? formatted.slice(prefix.length).trimStart() : digits;
}
const metadata = new Metadata();
/** Longest national number the country's numbering plan allows. */
export function maxNationalLength(country: CountryCode): number {
metadata.selectNumberingPlan(country);
return Math.max(...(metadata.numberingPlan?.possibleLengths() ?? [15]));
}
/**
* True when the typed digits exceed the country's longest number. Judged on
* the national part once it parses, so a trunk prefix (0911223344) or typed
* country code (251911223344) isn't counted against the limit.
*/
export function exceedsMaxLength(digits: string, country: CountryCode): boolean {
const national = parsePhoneNumberFromString(digits, country)?.nationalNumber ?? digits;
return national.length > maxNationalLength(country);
}

11
libs/ui/vite.config.mts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
watch: false,
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
reporters: ['default'],
},
});

View File

@@ -36,6 +36,7 @@
"i18n-nationality": "^1.4.0",
"i18next": "^25.6.0",
"js-cookie": "^3.0.8",
"libphonenumber-js": "^1.13.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.71.2",

8
pnpm-lock.yaml generated
View File

@@ -77,6 +77,9 @@ importers:
js-cookie:
specifier: ^3.0.8
version: 3.0.8
libphonenumber-js:
specifier: ^1.13.11
version: 1.13.11
react:
specifier: ^19.0.0
version: 19.2.8
@@ -4837,6 +4840,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
libphonenumber-js@1.13.11:
resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -12006,6 +12012,8 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
libphonenumber-js@1.13.11: {}
lightningcss-android-arm64@1.32.0:
optional: true

View File

@@ -1,4 +1,5 @@
allowBuilds:
canvas: set this to true or false
core-js: set this to true or false
esbuild: set this to true or false
nx: set this to true or false