mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -29,6 +29,7 @@ export type ActionId =
|
|||||||
| 'request-adjustment'
|
| 'request-adjustment'
|
||||||
| 'reject'
|
| 'reject'
|
||||||
| 'schedule-exam'
|
| 'schedule-exam'
|
||||||
|
| 'record-exam-outcome'
|
||||||
| 'confirm-payment'
|
| 'confirm-payment'
|
||||||
| 'schedule-issuance'
|
| 'schedule-issuance'
|
||||||
| 'issue-certificate'
|
| 'issue-certificate'
|
||||||
@@ -68,7 +69,9 @@ export const ACTIONS: ActionDefinition[] = [
|
|||||||
id: 'claim',
|
id: 'claim',
|
||||||
tier: 'workflow',
|
tier: 'workflow',
|
||||||
labelKey: 'review.actions.claim',
|
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'],
|
permissions: ['can:claim:license-application'],
|
||||||
emphasis: 'light',
|
emphasis: 'light',
|
||||||
},
|
},
|
||||||
@@ -198,7 +201,21 @@ export const ACTIONS: ActionDefinition[] = [
|
|||||||
// Only after the examination fee clears — scheduling an unpaid candidate
|
// Only after the examination fee clears — scheduling an unpaid candidate
|
||||||
// is what the EXAM_PAID gate exists to prevent.
|
// is what the EXAM_PAID gate exists to prevent.
|
||||||
from: ['EXAM_PAID'],
|
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',
|
emphasis: 'filled',
|
||||||
color: 'cyan',
|
color: 'cyan',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export function licenseQueueActionsColumn(
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
handlers.claimable !== false &&
|
handlers.claimable !== false &&
|
||||||
row.original.assignedOfficerId === null &&
|
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
|
<RequirePermission
|
||||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||||
hideOnly
|
hideOnly
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
|
|||||||
"INSPECTION_COMPLETED",
|
"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[] = [
|
const ALL_STATUSES: LicenseStatus[] = [
|
||||||
"SUBMITTED",
|
"SUBMITTED",
|
||||||
"UNDER_REVIEW",
|
"UNDER_REVIEW",
|
||||||
@@ -96,9 +107,11 @@ const ALL_STATUSES: LicenseStatus[] = [
|
|||||||
"INSPECTION_COMPLETED",
|
"INSPECTION_COMPLETED",
|
||||||
"ON_HOLD",
|
"ON_HOLD",
|
||||||
"APPROVED",
|
"APPROVED",
|
||||||
|
...EXAM_ONLY_STATUSES,
|
||||||
"PAYMENT_PENDING",
|
"PAYMENT_PENDING",
|
||||||
"PAID",
|
"PAID",
|
||||||
"PAYMENT_CONFIRMED",
|
"PAYMENT_CONFIRMED",
|
||||||
|
"SCHEDULED",
|
||||||
"CERTIFICATE_ISSUED",
|
"CERTIFICATE_ISSUED",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"REJECTED",
|
"REJECTED",
|
||||||
@@ -117,6 +130,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
|||||||
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
||||||
return type.inspectionRequired;
|
return type.inspectionRequired;
|
||||||
}
|
}
|
||||||
|
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
||||||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
useIssueCertificateMutation,
|
useIssueCertificateMutation,
|
||||||
useScheduleExamMutation,
|
useScheduleExamMutation,
|
||||||
|
useRecordExamOutcomeMutation,
|
||||||
useEscalateApplicationMutation,
|
useEscalateApplicationMutation,
|
||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useGetApplicationForReviewQuery,
|
useGetApplicationForReviewQuery,
|
||||||
@@ -209,6 +210,7 @@ export function LicenseReviewPage() {
|
|||||||
const [issueCertificate] = useIssueCertificateMutation();
|
const [issueCertificate] = useIssueCertificateMutation();
|
||||||
const [scheduleExam, { isLoading: schedulingExam }] =
|
const [scheduleExam, { isLoading: schedulingExam }] =
|
||||||
useScheduleExamMutation();
|
useScheduleExamMutation();
|
||||||
|
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
||||||
const [holdApplication] = useHoldApplicationMutation();
|
const [holdApplication] = useHoldApplicationMutation();
|
||||||
const [resumeApplication] = useResumeApplicationMutation();
|
const [resumeApplication] = useResumeApplicationMutation();
|
||||||
const [escalateApplication] = useEscalateApplicationMutation();
|
const [escalateApplication] = useEscalateApplicationMutation();
|
||||||
@@ -234,6 +236,8 @@ export function LicenseReviewPage() {
|
|||||||
const [issuanceDate, setIssuanceDate] = useState("");
|
const [issuanceDate, setIssuanceDate] = useState("");
|
||||||
const [resultOpen, setResultOpen] = useState(false);
|
const [resultOpen, setResultOpen] = useState(false);
|
||||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||||
|
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||||
|
const [examScore, setExamScore] = useState<number | undefined>();
|
||||||
const [findings, setFindings] = useState("");
|
const [findings, setFindings] = useState("");
|
||||||
const [checklist, setChecklist] = useState<
|
const [checklist, setChecklist] = useState<
|
||||||
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
||||||
@@ -490,6 +494,11 @@ export function LicenseReviewPage() {
|
|||||||
case "schedule-exam":
|
case "schedule-exam":
|
||||||
setScheduleExamOpen(true);
|
setScheduleExamOpen(true);
|
||||||
return;
|
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":
|
case "copy-link":
|
||||||
navigator.clipboard.writeText(window.location.href);
|
navigator.clipboard.writeText(window.location.href);
|
||||||
notifications.show({
|
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
|
<Modal
|
||||||
opened={inspectionOpen}
|
opened={inspectionOpen}
|
||||||
onClose={() => setInspectionOpen(false)}
|
onClose={() => setInspectionOpen(false)}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useApiMutation } from '@ema-platform/api';
|
||||||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||||||
import type { AuthUser } from '@ema-platform/auth';
|
import type { AuthUser } from '@ema-platform/auth';
|
||||||
@@ -126,9 +126,9 @@ export function ProfilePage() {
|
|||||||
.string()
|
.string()
|
||||||
.min(1, { message: t('profile.validation.usernameRequired') }),
|
.min(1, { message: t('profile.validation.usernameRequired') }),
|
||||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||||
phoneNumber: z
|
// Shared international rule: bare 09xxxxxxxx normalizes to +251, any
|
||||||
.string()
|
// other E.164 number is accepted as typed.
|
||||||
.min(1, { message: t('profile.validation.phoneRequired') }),
|
phoneNumber,
|
||||||
});
|
});
|
||||||
type ProfileValues = z.infer<typeof profileSchema>;
|
type ProfileValues = z.infer<typeof profileSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const am: Translations = {
|
|||||||
allApplications: "ሁሉም ማመልከቻዎች",
|
allApplications: "ሁሉም ማመልከቻዎች",
|
||||||
licenceRegister: "የፈቃድ መዝገብ",
|
licenceRegister: "የፈቃድ መዝገብ",
|
||||||
certificateDesigner: "የምስክር ወረቀት ንድፍ",
|
certificateDesigner: "የምስክር ወረቀት ንድፍ",
|
||||||
|
certificateRequirements: "የምስክር ወረቀት መስፈርቶች",
|
||||||
byType: "በዓይነት",
|
byType: "በዓይነት",
|
||||||
typeFreightForwarder: "የጭነት አስተላላፊ",
|
typeFreightForwarder: "የጭነት አስተላላፊ",
|
||||||
typeShippingAgent: "የመርከብ ወኪል",
|
typeShippingAgent: "የመርከብ ወኪል",
|
||||||
@@ -1201,6 +1202,124 @@ export const am: Translations = {
|
|||||||
noPublishPermission: "ንድፎችን ማተም አይችሉም",
|
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: {
|
seafarerRegistry: {
|
||||||
title: "የመርከበኞች መዝገብ",
|
title: "የመርከበኞች መዝገብ",
|
||||||
profileCount_one: "{{count}} መገለጫ",
|
profileCount_one: "{{count}} መገለጫ",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const en = {
|
|||||||
allApplications: 'All Applications',
|
allApplications: 'All Applications',
|
||||||
licenceRegister: 'Licence Register',
|
licenceRegister: 'Licence Register',
|
||||||
certificateDesigner: 'Certificate Designer',
|
certificateDesigner: 'Certificate Designer',
|
||||||
|
certificateRequirements: 'Certificate Requirements',
|
||||||
byType: 'By Type',
|
byType: 'By Type',
|
||||||
typeFreightForwarder: 'Freight Forwarder',
|
typeFreightForwarder: 'Freight Forwarder',
|
||||||
typeShippingAgent: 'Shipping Agent',
|
typeShippingAgent: 'Shipping Agent',
|
||||||
@@ -1204,6 +1205,124 @@ export const en = {
|
|||||||
noPublishPermission: 'You cannot publish designs',
|
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: {
|
seafarerRegistry: {
|
||||||
title: 'Seafarer registry',
|
title: 'Seafarer registry',
|
||||||
profileCount_one: '{{count}} profile',
|
profileCount_one: '{{count}} profile',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IconBook2,
|
IconBook2,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
|
IconClipboardText,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconFilePlus,
|
IconFilePlus,
|
||||||
@@ -144,6 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
icon: IconRosetteDiscountCheck,
|
icon: IconRosetteDiscountCheck,
|
||||||
permissions: [P.VIEW_TEMPLATES],
|
permissions: [P.VIEW_TEMPLATES],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/certificate-requirements',
|
||||||
|
label: 'nav.certificateRequirements',
|
||||||
|
icon: IconClipboardText,
|
||||||
|
permissions: [P.VIEW_LICENSE_TYPES],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/payment-config',
|
to: '/payment-config',
|
||||||
label: 'nav.paymentConfig',
|
label: 'nav.paymentConfig',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { LicenseRegisterPage } from '../features/license-register/pages/LicenseR
|
|||||||
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
||||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
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. */
|
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
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 /> },
|
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
// Config-driven review workspace, shared by every licence type.
|
// Config-driven review workspace, shared by every licence type.
|
||||||
{ path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
|
{ 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-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
|
||||||
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
|
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
|
||||||
// Deep link into the grid with the type facet pinned, so "Freight
|
// Deep link into the grid with the type facet pinned, so "Freight
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
useGetMyMedicalCertificatesQuery,
|
useGetMyMedicalCertificatesQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||||
|
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mock data
|
// Mock data
|
||||||
@@ -55,6 +56,9 @@ interface CertificatesOverview {
|
|||||||
type: string;
|
type: string;
|
||||||
submitted: string;
|
submitted: string;
|
||||||
status: 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',
|
RESUBMIT_REQUIRED: 'orange',
|
||||||
INSPECTION_PENDING: 'grape',
|
INSPECTION_PENDING: 'grape',
|
||||||
INSPECTION_COMPLETED: 'grape',
|
INSPECTION_COMPLETED: 'grape',
|
||||||
ELIGIBILITY_APPROVED: 'teal',
|
ELIGIBILITY_PAYMENT_PENDING: 'orange',
|
||||||
|
ELIGIBILITY_PAID: 'blue',
|
||||||
EXAM_PAYMENT_PENDING: 'orange',
|
EXAM_PAYMENT_PENDING: 'orange',
|
||||||
EXAM_PAID: 'blue',
|
EXAM_PAID: 'blue',
|
||||||
EXAM_SCHEDULED: 'indigo',
|
EXAM_SCHEDULED: 'indigo',
|
||||||
@@ -140,6 +145,7 @@ export function CertificatesPage() {
|
|||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [previewTitle, setPreviewTitle] = useState('');
|
const [previewTitle, setPreviewTitle] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { pay, isPaying } = useApplicationPayment();
|
||||||
|
|
||||||
const { data } = useApiQuery<CertificatesOverview>({
|
const { data } = useApiQuery<CertificatesOverview>({
|
||||||
url: '/certificates/my',
|
url: '/certificates/my',
|
||||||
@@ -220,14 +226,25 @@ export function CertificatesPage() {
|
|||||||
{/* Tooltip needs a hoverable child even while the button itself is
|
{/* Tooltip needs a hoverable child even while the button itself is
|
||||||
disabled, so the reason still shows on hover. */}
|
disabled, so the reason still shows on hover. */}
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Group gap="xs">
|
||||||
leftSection={<IconShieldCheck size={15} />}
|
<Button
|
||||||
rightSection={<IconArrowRight size={15} />}
|
leftSection={<IconShieldCheck size={15} />}
|
||||||
onClick={() => navigate('/certificates/apply')}
|
rightSection={<IconArrowRight size={15} />}
|
||||||
disabled={!canApply}
|
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
|
||||||
>
|
disabled={!canApply}
|
||||||
Apply for CoC / CoP
|
>
|
||||||
</Button>
|
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>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -269,7 +286,7 @@ export function CertificatesPage() {
|
|||||||
<Text fw={700} mb="md">My Applications</Text>
|
<Text fw={700} mb="md">My Applications</Text>
|
||||||
{applications.length === 0 ? (
|
{applications.length === 0 ? (
|
||||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
<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>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||||
@@ -301,14 +318,28 @@ export function CertificatesPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
fz="xs"
|
{/* feeAmount is non-null exactly when the server would
|
||||||
c="blue"
|
accept a payment, so the button and the API agree. */}
|
||||||
style={{ cursor: 'pointer' }}
|
{app.feeAmount !== null && (
|
||||||
onClick={() => navigate(`/applications/${app.applicationId}`)}
|
<Button
|
||||||
>
|
size="xs"
|
||||||
Details
|
color="yellow"
|
||||||
</Text>
|
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.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import {
|
|||||||
type FormSectionConfig,
|
type FormSectionConfig,
|
||||||
type Vessel,
|
type Vessel,
|
||||||
} from '@ema-platform/api';
|
} 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 { useTranslation } from 'react-i18next';
|
||||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||||
|
|
||||||
@@ -223,6 +223,12 @@ export function ConfigDrivenSection({
|
|||||||
value={(value as string) ?? ''}
|
value={(value as string) ?? ''}
|
||||||
onChange={(v) => onChange(field.key, v)}
|
onChange={(v) => onChange(field.key, v)}
|
||||||
/>
|
/>
|
||||||
|
) : field.type === 'PHONE' ? (
|
||||||
|
<PhoneInput
|
||||||
|
{...common}
|
||||||
|
value={(value as string) ?? ''}
|
||||||
|
onChange={(v) => onChange(field.key, v)}
|
||||||
|
/>
|
||||||
) : field.type === 'TEXTAREA' ? (
|
) : field.type === 'TEXTAREA' ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
{...common}
|
{...common}
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ export function DocumentSlots({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
{requirement.description && (
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{localized(requirement.description)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{existing?.files?.[0] && (
|
{existing?.files?.[0] && (
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{existing.files[0].originalName} ·{' '}
|
{existing.files[0].originalName} ·{' '}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ interface Props {
|
|||||||
t: TFunction;
|
t: TFunction;
|
||||||
requesting: boolean;
|
requesting: boolean;
|
||||||
paying: boolean;
|
paying: boolean;
|
||||||
onRequestExamFee: (app: LicenseApplication) => void;
|
onRetakeExam: (app: LicenseApplication) => void;
|
||||||
onPay: (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
|
* 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
|
* occur on types that examine — folding them into that column would put five
|
||||||
@@ -26,24 +27,50 @@ export function ExamStageActions({
|
|||||||
t,
|
t,
|
||||||
requesting,
|
requesting,
|
||||||
paying,
|
paying,
|
||||||
onRequestExamFee,
|
onRetakeExam,
|
||||||
onPay,
|
onPay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Eligible but not yet committed to sitting, or sat and not passed: both are
|
// The eligibility fee is invoiced the moment the application is submitted —
|
||||||
// the same decision — ask for the fee that buys a sitting.
|
// there is no separate "request" step, so this is a pay button, exactly
|
||||||
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
|
// like EXAM_PAYMENT_PENDING below.
|
||||||
const retake = app.status === 'EXAM_FAILED';
|
if (app.status === 'ELIGIBILITY_PAYMENT_PENDING') {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color={retake ? 'orange' : 'teal'}
|
color="yellow"
|
||||||
loading={requesting}
|
loading={paying}
|
||||||
onClick={() => onRequestExamFee(app)}
|
onClick={() => onPay(app)}
|
||||||
>
|
>
|
||||||
{retake
|
{t('applications.actions.payEligibilityFee', {
|
||||||
? t('applications.actions.bookRetake', 'Book a resit')
|
defaultValue: 'Pay eligibility fee ({{amount}} {{currency}})',
|
||||||
: t('applications.actions.bookExam', 'Book exam')}
|
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>
|
</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
|
// Paid and scheduled are both waiting states — nothing for the candidate to
|
||||||
// do, so say so rather than offering a button that does nothing.
|
// do, so say so rather than offering a button that does nothing.
|
||||||
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {
|
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {
|
||||||
|
|||||||
@@ -270,13 +270,18 @@ export function LicenseApplicationPage() {
|
|||||||
const nameFallback = accountName?.en
|
const nameFallback = accountName?.en
|
||||||
? splitPersonName(accountName.en)
|
? splitPersonName(accountName.en)
|
||||||
: null;
|
: null;
|
||||||
|
const firstName = profile.firstName || nameFallback?.firstName || "";
|
||||||
|
const middleName = profile.middleName || nameFallback?.middleName || "";
|
||||||
|
const lastName = profile.lastName || nameFallback?.lastName || "";
|
||||||
const context = {
|
const context = {
|
||||||
user: accountUser ?? profile.user,
|
user: accountUser ?? profile.user,
|
||||||
profile: {
|
profile: {
|
||||||
...profile,
|
...profile,
|
||||||
firstName: profile.firstName || nameFallback?.firstName || "",
|
firstName,
|
||||||
middleName: profile.middleName || nameFallback?.middleName || "",
|
middleName,
|
||||||
lastName: profile.lastName || nameFallback?.lastName || "",
|
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 =
|
const untouched =
|
||||||
current === undefined || current === null || current === "";
|
current === undefined || current === null || current === "";
|
||||||
if (!field.readOnly && !untouched) continue;
|
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 (value === undefined || value === null || value === "") continue;
|
||||||
if (current === value) continue;
|
if (current === value) continue;
|
||||||
next[section.key] = { ...next[section.key], [field.key]: value };
|
next[section.key] = { ...next[section.key], [field.key]: value };
|
||||||
@@ -727,9 +737,14 @@ export function LicenseApplicationPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={section.key}>
|
<div key={section.key}>
|
||||||
{index > 0 && <Divider mb="lg" />}
|
{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)}
|
{localized(section.title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{section.description && (
|
||||||
|
<Text fz="xs" c="dimmed" mb="sm">
|
||||||
|
{localized(section.description)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{locked && (
|
{locked && (
|
||||||
<Alert
|
<Alert
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { ExamStageActions } from '../../components/ExamStageActions';
|
|||||||
|
|
||||||
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
||||||
const EXAM_STAGE_STATUSES = [
|
const EXAM_STAGE_STATUSES = [
|
||||||
'ELIGIBILITY_APPROVED',
|
'ELIGIBILITY_PAYMENT_PENDING',
|
||||||
|
'ELIGIBILITY_PAID',
|
||||||
'EXAM_PAYMENT_PENDING',
|
'EXAM_PAYMENT_PENDING',
|
||||||
'EXAM_PAID',
|
'EXAM_PAID',
|
||||||
'EXAM_SCHEDULED',
|
'EXAM_SCHEDULED',
|
||||||
'EXAM_FAILED',
|
'EXAM_FAILED',
|
||||||
|
'EXAM_PASSED',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function applicationActionsColumn(
|
export function applicationActionsColumn(
|
||||||
@@ -23,12 +25,12 @@ export function applicationActionsColumn(
|
|||||||
bypassEnabled: boolean;
|
bypassEnabled: boolean;
|
||||||
bypassing: boolean;
|
bypassing: boolean;
|
||||||
isPaying: 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;
|
requestingExamFee: boolean;
|
||||||
onBypass: (app: LicenseApplication) => void;
|
onBypass: (app: LicenseApplication) => void;
|
||||||
onCertificate: (app: LicenseApplication) => void;
|
onCertificate: (app: LicenseApplication) => void;
|
||||||
onPay: (app: LicenseApplication) => void;
|
onPay: (app: LicenseApplication) => void;
|
||||||
onRequestExamFee: (app: LicenseApplication) => void;
|
onRetakeExam: (app: LicenseApplication) => void;
|
||||||
onOpen: (app: LicenseApplication) => void;
|
onOpen: (app: LicenseApplication) => void;
|
||||||
},
|
},
|
||||||
): AdvancedColumn<LicenseApplication> {
|
): AdvancedColumn<LicenseApplication> {
|
||||||
@@ -46,14 +48,17 @@ export function applicationActionsColumn(
|
|||||||
t={t}
|
t={t}
|
||||||
requesting={deps.requestingExamFee}
|
requesting={deps.requestingExamFee}
|
||||||
paying={deps.isPaying}
|
paying={deps.isPaying}
|
||||||
onRequestExamFee={deps.onRequestExamFee}
|
onRetakeExam={deps.onRetakeExam}
|
||||||
onPay={deps.onPay}
|
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. */}
|
otherwise untestable without a live gateway. */}
|
||||||
{deps.bypassEnabled &&
|
{deps.bypassEnabled &&
|
||||||
(app.status === 'PAYMENT_PENDING' ||
|
(app.status === 'PAYMENT_PENDING' ||
|
||||||
app.status === 'EXAM_PAYMENT_PENDING') && (
|
app.status === 'ELIGIBILITY_PAYMENT_PENDING' ||
|
||||||
|
app.status === 'EXAM_PAYMENT_PENDING' ||
|
||||||
|
app.status === 'EXAM_PASSED') && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
useRequestExamPaymentMutation,
|
useRetakeExamMutation,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
@@ -99,8 +99,7 @@ export function MyApplicationsPage() {
|
|||||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||||
const [requestExamPayment, { isLoading: requestingExamFee }] =
|
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||||
useRequestExamPaymentMutation();
|
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
const { renewLicense, isRenewing } = useRenewLicense();
|
const { renewLicense, isRenewing } = useRenewLicense();
|
||||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
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
|
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
|
||||||
* the provider the same way every other fee does.
|
* the provider the same way every other fee does.
|
||||||
*/
|
*/
|
||||||
async function requestExamFee(applicationId: string) {
|
async function retakeExamFee(applicationId: string) {
|
||||||
try {
|
try {
|
||||||
await requestExamPayment(applicationId).unwrap();
|
await retakeExam(applicationId).unwrap();
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'teal',
|
color: 'teal',
|
||||||
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
||||||
@@ -281,7 +280,7 @@ export function MyApplicationsPage() {
|
|||||||
onBypass: (app) => handleBypass(app.id),
|
onBypass: (app) => handleBypass(app.id),
|
||||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||||
onPay: (app) => pay(app.id),
|
onPay: (app) => pay(app.id),
|
||||||
onRequestExamFee: (app) => requestExamFee(app.id),
|
onRetakeExam: (app) => retakeExamFee(app.id),
|
||||||
onOpen: (app) =>
|
onOpen: (app) =>
|
||||||
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFo
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { useTranslation } from 'react-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 { LocationPicker } from '../../location/components/LocationPicker';
|
||||||
import { useGetLocationTypesQuery } from '../../location/api/location-api';
|
import { useGetLocationTypesQuery } from '../../location/api/location-api';
|
||||||
import type { Location, LocationType } from '../../location/types/location';
|
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')),
|
idNumber: z.string().trim().min(1, t('profileAddress.validation.idNumberRequired')),
|
||||||
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
|
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
|
||||||
nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
|
nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
|
||||||
primaryPhoneNumber: ethiopianPhone,
|
primaryPhoneNumber: phoneNumber,
|
||||||
secondaryPhoneNumber: optionalEthiopianPhone,
|
secondaryPhoneNumber: optionalPhoneNumber,
|
||||||
email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
|
email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
|
||||||
regionId: z.string().optional(),
|
regionId: z.string().optional(),
|
||||||
cityId: 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
|
// Emergency contact is collected but never required — leaving it blank must
|
||||||
// not stop an applicant moving on.
|
// not stop an applicant moving on.
|
||||||
emergencyContactName: z.string().trim().optional(),
|
emergencyContactName: z.string().trim().optional(),
|
||||||
emergencyContactPhone: optionalEthiopianPhone,
|
emergencyContactPhone: optionalPhoneNumber,
|
||||||
emergencyContactRelation: z.string().trim().optional(),
|
emergencyContactRelation: z.string().trim().optional(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -147,18 +147,22 @@ export function AddressFormContent({
|
|||||||
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
|
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
|
||||||
error={errors.nationality?.message}
|
error={errors.nationality?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label={t('profileFields.primaryPhoneNumber')}
|
label={t('profileFields.primaryPhoneNumber')}
|
||||||
description={t('profileAddress.accountManagedHint')}
|
description={t('profileAddress.accountManagedHint')}
|
||||||
required
|
required
|
||||||
readOnly
|
readOnly
|
||||||
{...register('primaryPhoneNumber')}
|
value={watch('primaryPhoneNumber') || ''}
|
||||||
|
onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: !!errors.primaryPhoneNumber })}
|
||||||
|
onBlur={() => trigger('primaryPhoneNumber')}
|
||||||
error={errors.primaryPhoneNumber?.message}
|
error={errors.primaryPhoneNumber?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label={t('profileAddress.secondaryPhoneNumber')}
|
label={t('profileAddress.secondaryPhoneNumber')}
|
||||||
placeholder={t('profileAddress.phonePlaceholder')}
|
placeholder={t('profileAddress.phonePlaceholder')}
|
||||||
{...register('secondaryPhoneNumber')}
|
value={watch('secondaryPhoneNumber') || ''}
|
||||||
|
onChange={(val) => setValue('secondaryPhoneNumber', val, { shouldValidate: !!errors.secondaryPhoneNumber })}
|
||||||
|
onBlur={() => trigger('secondaryPhoneNumber')}
|
||||||
error={errors.secondaryPhoneNumber?.message}
|
error={errors.secondaryPhoneNumber?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -206,10 +210,12 @@ export function AddressFormContent({
|
|||||||
{...register('emergencyContactName')}
|
{...register('emergencyContactName')}
|
||||||
error={errors.emergencyContactName?.message}
|
error={errors.emergencyContactName?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label={t('profileAddress.contactPhone')}
|
label={t('profileAddress.contactPhone')}
|
||||||
placeholder={t('profileAddress.phonePlaceholder')}
|
placeholder={t('profileAddress.phonePlaceholder')}
|
||||||
{...register('emergencyContactPhone')}
|
value={watch('emergencyContactPhone') || ''}
|
||||||
|
onChange={(val) => setValue('emergencyContactPhone', val, { shouldValidate: !!errors.emergencyContactPhone })}
|
||||||
|
onBlur={() => trigger('emergencyContactPhone')}
|
||||||
error={errors.emergencyContactPhone?.message}
|
error={errors.emergencyContactPhone?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ function isAtLeast18(dob: string): boolean {
|
|||||||
return birth <= cutoff;
|
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({
|
z.object({
|
||||||
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
|
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
|
||||||
firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
|
firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
|
||||||
@@ -28,8 +33,32 @@ export const profileSchema = (t: TFunction) =>
|
|||||||
.refine((value) => isAtLeast18(value), {
|
.refine((value) => isAtLeast18(value), {
|
||||||
message: t('profileForm.validation.dobMinAge'),
|
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')),
|
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>>;
|
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 GENDERS = ['MALE', 'FEMALE'] as const;
|
||||||
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
|
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
|
||||||
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] 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 {
|
interface ProfileFormContentProps {
|
||||||
register: UseFormRegister<ProfileValues>;
|
register: UseFormRegister<ProfileValues>;
|
||||||
@@ -46,6 +82,8 @@ interface ProfileFormContentProps {
|
|||||||
trigger: UseFormTrigger<ProfileValues>;
|
trigger: UseFormTrigger<ProfileValues>;
|
||||||
professionsLoading: boolean;
|
professionsLoading: boolean;
|
||||||
professionOptions: Array<{ value: string; label: string }>;
|
professionOptions: Array<{ value: string; label: string }>;
|
||||||
|
/** Place of birth, hair/eye colour and height become required for these accounts. */
|
||||||
|
isSeafarer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileFormContent({
|
export function ProfileFormContent({
|
||||||
@@ -56,6 +94,7 @@ export function ProfileFormContent({
|
|||||||
trigger,
|
trigger,
|
||||||
professionsLoading,
|
professionsLoading,
|
||||||
professionOptions,
|
professionOptions,
|
||||||
|
isSeafarer,
|
||||||
}: ProfileFormContentProps) {
|
}: ProfileFormContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -119,6 +158,7 @@ export function ProfileFormContent({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={t('profileFields.pob')}
|
label={t('profileFields.pob')}
|
||||||
placeholder={t('profileForm.placeholders.pob')}
|
placeholder={t('profileForm.placeholders.pob')}
|
||||||
|
required={isSeafarer}
|
||||||
{...register('pob')}
|
{...register('pob')}
|
||||||
error={errors.pob?.message}
|
error={errors.pob?.message}
|
||||||
/>
|
/>
|
||||||
@@ -133,6 +173,50 @@ export function ProfileFormContent({
|
|||||||
onBlur={() => trigger('maritalStatus')}
|
onBlur={() => trigger('maritalStatus')}
|
||||||
name="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>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
IconBuildingWarehouse,
|
IconBuildingWarehouse,
|
||||||
IconMapPin,
|
IconMapPin,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconPhone,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
IconSun,
|
IconSun,
|
||||||
@@ -49,7 +48,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||||
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||||
@@ -187,6 +186,12 @@ export function ProfilePage() {
|
|||||||
const showSeafarerBanner =
|
const showSeafarerBanner =
|
||||||
can([PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]) &&
|
can([PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]) &&
|
||||||
!isReadyFor(SEAFARER_PROFILE_REQUIREMENT);
|
!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 [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
||||||
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
|
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
|
||||||
@@ -224,6 +229,10 @@ export function ProfilePage() {
|
|||||||
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
|
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
|
||||||
pob: currentProfile.pob || '',
|
pob: currentProfile.pob || '',
|
||||||
maritalStatus: currentProfile.maritalStatus || '',
|
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
|
// 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') }),
|
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||||
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
||||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
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>;
|
type PersonalValues = z.infer<typeof personalSchema>;
|
||||||
|
|
||||||
@@ -278,6 +287,9 @@ export function ProfilePage() {
|
|||||||
register: registerPersonal,
|
register: registerPersonal,
|
||||||
handleSubmit: handlePersonalSubmit,
|
handleSubmit: handlePersonalSubmit,
|
||||||
reset: resetPersonal,
|
reset: resetPersonal,
|
||||||
|
watch: watchPersonal,
|
||||||
|
setValue: setValuePersonal,
|
||||||
|
trigger: triggerPersonal,
|
||||||
formState: { errors: personalErrors },
|
formState: { errors: personalErrors },
|
||||||
} = useForm<PersonalValues>({
|
} = useForm<PersonalValues>({
|
||||||
resolver: zodResolver(personalSchema),
|
resolver: zodResolver(personalSchema),
|
||||||
@@ -365,7 +377,7 @@ export function ProfilePage() {
|
|||||||
trigger: profileTriggerValidation,
|
trigger: profileTriggerValidation,
|
||||||
formState: { errors: profileErrors },
|
formState: { errors: profileErrors },
|
||||||
} = useForm<ProfileValues>({
|
} = useForm<ProfileValues>({
|
||||||
resolver: zodResolver(profileSchema(t)),
|
resolver: zodResolver(profileSchema(t, isSeafarer)),
|
||||||
values: loadedProfile ?? undefined,
|
values: loadedProfile ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -383,7 +395,15 @@ export function ProfilePage() {
|
|||||||
await updateProfile({
|
await updateProfile({
|
||||||
url: `/profiles/${profileId}`,
|
url: `/profiles/${profileId}`,
|
||||||
method: 'PUT',
|
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();
|
}).unwrap();
|
||||||
setLoadedProfile({ ...values, pob: values.pob ?? '' });
|
setLoadedProfile({ ...values, pob: values.pob ?? '' });
|
||||||
|
|
||||||
@@ -669,11 +689,12 @@ export function ProfilePage() {
|
|||||||
error={personalErrors.email?.message}
|
error={personalErrors.email?.message}
|
||||||
{...registerPersonal('email')}
|
{...registerPersonal('email')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label={t('profile.fields.phone')}
|
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}
|
error={personalErrors.phoneNumber?.message}
|
||||||
{...registerPersonal('phoneNumber')}
|
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
@@ -724,6 +745,7 @@ export function ProfilePage() {
|
|||||||
trigger={profileTriggerValidation}
|
trigger={profileTriggerValidation}
|
||||||
professionsLoading={professionsLoading}
|
professionsLoading={professionsLoading}
|
||||||
professionOptions={professionOptions}
|
professionOptions={professionOptions}
|
||||||
|
isSeafarer={isSeafarer}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ import {
|
|||||||
IconCheck,
|
IconCheck,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconPhone,
|
|
||||||
IconShip,
|
IconShip,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
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 = [
|
const OWNER_TYPES = [
|
||||||
'Individual (Private Owner)',
|
'Individual (Private Owner)',
|
||||||
@@ -51,7 +51,7 @@ export function VesselOwnerRegisterPage() {
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [registerTrigger] = useApiMutation<{ id: string }>();
|
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 () => {
|
const handleRegister = async () => {
|
||||||
if (!canSubmit) {
|
if (!canSubmit) {
|
||||||
@@ -143,13 +143,11 @@ export function VesselOwnerRegisterPage() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label="Phone Number"
|
label="Phone Number"
|
||||||
placeholder="+251 9XX XXX XXX"
|
|
||||||
leftSection={<IconPhone size={16} />}
|
|
||||||
required
|
required
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.currentTarget.value)}
|
onChange={setPhone}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="National ID / TIN"
|
label="National ID / TIN"
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import {
|
|||||||
IconTransferIn,
|
IconTransferIn,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from '@tabler/icons-react';
|
} 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
|
// Minimal vessel type for the approved vessel list
|
||||||
interface ApprovedVessel {
|
interface ApprovedVessel {
|
||||||
@@ -208,7 +209,7 @@ export function OwnershipTransferPage() {
|
|||||||
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
|
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
|
||||||
|
|
||||||
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
|
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
|
||||||
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
|
isValidPhoneNumber(newOwnerPhone) && !!transferReason && !!billOfSale;
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setSelectedVesselId(null);
|
setSelectedVesselId(null);
|
||||||
@@ -393,7 +394,7 @@ export function OwnershipTransferPage() {
|
|||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<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="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="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="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)} />
|
<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} />
|
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ import {
|
|||||||
IconShip,
|
IconShip,
|
||||||
IconWaveSine,
|
IconWaveSine,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, PhoneInput } from '@ema-platform/ui';
|
||||||
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -311,7 +312,7 @@ export function VesselRegistrationApplicationPage() {
|
|||||||
if (active === 2) return (
|
if (active === 2) return (
|
||||||
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
|
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
|
||||||
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
|
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
|
||||||
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
|
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && isValidPhoneNumber(ownerPhone)
|
||||||
);
|
);
|
||||||
if (active === 3) return category === 'Inland Waterway Vessel'
|
if (active === 3) return category === 'Inland Waterway Vessel'
|
||||||
? !!files.vesselPhotos
|
? !!files.vesselPhotos
|
||||||
@@ -551,12 +552,11 @@ export function VesselRegistrationApplicationPage() {
|
|||||||
value={ownerNationalIdOrTin}
|
value={ownerNationalIdOrTin}
|
||||||
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
|
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label="Owner Phone"
|
label="Owner Phone"
|
||||||
placeholder="+251 9XX XXX XXX"
|
|
||||||
required
|
required
|
||||||
value={ownerPhone}
|
value={ownerPhone}
|
||||||
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
|
onChange={setOwnerPhone}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Owner Address"
|
label="Owner Address"
|
||||||
|
|||||||
@@ -258,6 +258,10 @@ export const am: Translations = {
|
|||||||
dob: 'የትውልድ ቀን',
|
dob: 'የትውልድ ቀን',
|
||||||
pob: 'የትውልድ ቦታ',
|
pob: 'የትውልድ ቦታ',
|
||||||
maritalStatus: 'የጋብቻ ሁኔታ',
|
maritalStatus: 'የጋብቻ ሁኔታ',
|
||||||
|
bloodType: 'የደም አይነት',
|
||||||
|
hairColor: 'የፀጉር ቀለም',
|
||||||
|
eyeColor: 'የአይን ቀለም',
|
||||||
|
heightCm: 'ቁመት (ሴ.ሜ)',
|
||||||
professionId: 'ሙያ',
|
professionId: 'ሙያ',
|
||||||
idType: 'የመታወቂያ ዓይነት',
|
idType: 'የመታወቂያ ዓይነት',
|
||||||
idNumber: 'የመታወቂያ ቁጥር',
|
idNumber: 'የመታወቂያ ቁጥር',
|
||||||
@@ -436,6 +440,7 @@ export const am: Translations = {
|
|||||||
middleName: 'የአባት ስም ያስገቡ',
|
middleName: 'የአባት ስም ያስገቡ',
|
||||||
lastName: 'የአያት ስም ያስገቡ',
|
lastName: 'የአያት ስም ያስገቡ',
|
||||||
pob: 'ከተማ፣ ክልል',
|
pob: 'ከተማ፣ ክልል',
|
||||||
|
heightCm: 'ለምሳሌ 175',
|
||||||
},
|
},
|
||||||
genders: {
|
genders: {
|
||||||
MALE: 'ወንድ',
|
MALE: 'ወንድ',
|
||||||
@@ -447,6 +452,36 @@ export const am: Translations = {
|
|||||||
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: 'የማይታወቅ',
|
||||||
|
},
|
||||||
|
hairColors: {
|
||||||
|
BLACK: 'ጥቁር',
|
||||||
|
BROWN: 'ቡናማ',
|
||||||
|
BLONDE: 'ወርቃማ',
|
||||||
|
RED: 'ቀይ',
|
||||||
|
GREY: 'ግራጫ',
|
||||||
|
WHITE: 'ነጭ',
|
||||||
|
BALD: 'ራሰ በራ',
|
||||||
|
OTHER: 'ሌላ',
|
||||||
|
},
|
||||||
|
eyeColors: {
|
||||||
|
BROWN: 'ቡናማ',
|
||||||
|
BLACK: 'ጥቁር',
|
||||||
|
BLUE: 'ሰማያዊ',
|
||||||
|
GREEN: 'አረንጓዴ',
|
||||||
|
HAZEL: 'ኮክ ቡናማ',
|
||||||
|
GREY: 'ግራጫ',
|
||||||
|
OTHER: 'ሌላ',
|
||||||
|
},
|
||||||
validation: {
|
validation: {
|
||||||
professionRequired: 'ሙያዎን ይምረጡ',
|
professionRequired: 'ሙያዎን ይምረጡ',
|
||||||
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
|
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
|
||||||
@@ -457,6 +492,12 @@ export const am: Translations = {
|
|||||||
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
|
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
|
||||||
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
|
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
|
||||||
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
|
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
|
||||||
|
heightRange: 'ቁመት ከ100 እስከ 250 ሴ.ሜ መሆን አለበት',
|
||||||
|
pobRequired: 'የትውልድ ቦታዎን ያስገቡ',
|
||||||
|
bloodTypeRequired: 'የደም አይነትዎን ይምረጡ — ካልተመረመሩ የማይታወቅ ይምረጡ',
|
||||||
|
hairColorRequired: 'የፀጉር ቀለምዎን ይምረጡ',
|
||||||
|
eyeColorRequired: 'የአይን ቀለምዎን ይምረጡ',
|
||||||
|
heightRequired: 'ቁመትዎን ያስገቡ',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -542,7 +583,7 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
login: {
|
login: {
|
||||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
|
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ",
|
||||||
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
||||||
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
||||||
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
||||||
@@ -577,7 +618,7 @@ export const am: Translations = {
|
|||||||
usernameLabel: "የተጠቃሚ ስም",
|
usernameLabel: "የተጠቃሚ ስም",
|
||||||
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
||||||
phoneLabel: "ስልክ ቁጥር",
|
phoneLabel: "ስልክ ቁጥር",
|
||||||
phonePlaceholder: "+251 911 234 567",
|
phonePlaceholder: "9XX XXX XXX",
|
||||||
passwordLabel: "የይለፍ ቃል",
|
passwordLabel: "የይለፍ ቃል",
|
||||||
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
||||||
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
||||||
@@ -692,7 +733,7 @@ export const am: Translations = {
|
|||||||
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
|
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
|
||||||
idTypePlaceholder: 'ይምረጡ',
|
idTypePlaceholder: 'ይምረጡ',
|
||||||
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
|
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
|
||||||
phonePlaceholder: '+251 9XX XXX XXX',
|
phonePlaceholder: '9XX XXX XXX',
|
||||||
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
|
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
|
||||||
postalAddressPlaceholder: 'ፖስታ ሳጥን',
|
postalAddressPlaceholder: 'ፖስታ ሳጥን',
|
||||||
contactNamePlaceholder: 'ሙሉ ስም',
|
contactNamePlaceholder: 'ሙሉ ስም',
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ export const en = {
|
|||||||
dob: 'Date of birth',
|
dob: 'Date of birth',
|
||||||
pob: 'Place of birth',
|
pob: 'Place of birth',
|
||||||
maritalStatus: 'Marital status',
|
maritalStatus: 'Marital status',
|
||||||
|
bloodType: 'Blood type',
|
||||||
|
hairColor: 'Hair color',
|
||||||
|
eyeColor: 'Eye color',
|
||||||
|
heightCm: 'Height (cm)',
|
||||||
professionId: 'Profession',
|
professionId: 'Profession',
|
||||||
idType: 'ID type',
|
idType: 'ID type',
|
||||||
idNumber: 'ID number',
|
idNumber: 'ID number',
|
||||||
@@ -435,6 +439,7 @@ export const en = {
|
|||||||
middleName: 'Enter middle name',
|
middleName: 'Enter middle name',
|
||||||
lastName: 'Enter last name',
|
lastName: 'Enter last name',
|
||||||
pob: 'City, Region',
|
pob: 'City, Region',
|
||||||
|
heightCm: 'e.g. 175',
|
||||||
},
|
},
|
||||||
genders: {
|
genders: {
|
||||||
MALE: 'Male',
|
MALE: 'Male',
|
||||||
@@ -446,6 +451,36 @@ export const en = {
|
|||||||
DIVORCED: 'Divorced',
|
DIVORCED: 'Divorced',
|
||||||
WIDOWED: 'Widowed',
|
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: {
|
validation: {
|
||||||
professionRequired: 'Select your profession',
|
professionRequired: 'Select your profession',
|
||||||
firstNameMin: 'First name must be at least 3 characters',
|
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',
|
dobMinAge: 'You must be at least 18 years old',
|
||||||
maritalStatusRequired: 'Select your marital status',
|
maritalStatusRequired: 'Select your marital status',
|
||||||
nameParts: 'Enter your first, middle, and last name',
|
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: {
|
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',
|
passwordMinLength: 'Password must be at least 8 characters',
|
||||||
welcome: 'Welcome to {{appName}}',
|
welcome: 'Welcome to {{appName}}',
|
||||||
subtitle: 'Sign in to access your account.',
|
subtitle: 'Sign in to access your account.',
|
||||||
@@ -576,7 +617,7 @@ export const en = {
|
|||||||
usernameLabel: 'Username',
|
usernameLabel: 'Username',
|
||||||
usernamePlaceholder: 'Choose a username',
|
usernamePlaceholder: 'Choose a username',
|
||||||
phoneLabel: 'Phone number',
|
phoneLabel: 'Phone number',
|
||||||
phonePlaceholder: '+251 911 234 567',
|
phonePlaceholder: '9XX XXX XXX',
|
||||||
passwordLabel: 'Password',
|
passwordLabel: 'Password',
|
||||||
passwordPlaceholder: 'At least 8 characters',
|
passwordPlaceholder: 'At least 8 characters',
|
||||||
confirmPasswordLabel: 'Confirm password',
|
confirmPasswordLabel: 'Confirm password',
|
||||||
@@ -691,7 +732,7 @@ export const en = {
|
|||||||
accountManagedHint: 'From your account, edit it in the Personal tab',
|
accountManagedHint: 'From your account, edit it in the Personal tab',
|
||||||
idTypePlaceholder: 'Select',
|
idTypePlaceholder: 'Select',
|
||||||
idNumberPlaceholder: 'Enter ID number',
|
idNumberPlaceholder: 'Enter ID number',
|
||||||
phonePlaceholder: '+251 9XX XXX XXX',
|
phonePlaceholder: '9XX XXX XXX',
|
||||||
streetAddressPlaceholder: 'Street name, house number',
|
streetAddressPlaceholder: 'Street name, house number',
|
||||||
postalAddressPlaceholder: 'P.O. Box',
|
postalAddressPlaceholder: 'P.O. Box',
|
||||||
contactNamePlaceholder: 'Full name',
|
contactNamePlaceholder: 'Full name',
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { NotificationsPage } from "./features/notifications/pages/NotificationsP
|
|||||||
|
|
||||||
// Phase 2 — CoC / CoP
|
// Phase 2 — CoC / CoP
|
||||||
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||||
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||||
@@ -69,6 +68,10 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "/set-password", element: <SetPasswordPage /> },
|
{ path: "/set-password", element: <SetPasswordPage /> },
|
||||||
{ path: "/reset-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
|
// Protected auth pages
|
||||||
{
|
{
|
||||||
element: (
|
element: (
|
||||||
@@ -78,14 +81,6 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
path: "/otp-verify",
|
path: "/otp-verify",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
element: (
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ForgotPasswordPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
),
|
|
||||||
path: "/forgot-password",
|
|
||||||
},
|
|
||||||
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
|
// 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,
|
// profile details are collected where they are actually needed: on /profile,
|
||||||
// via the dashboard nudge, or inline in an application flow. The path stays
|
// via the dashboard nudge, or inline in an application flow. The path stays
|
||||||
@@ -275,14 +270,9 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
// CoC/CoP applications go through the generic license wizard —
|
||||||
path: "/certificates/apply",
|
// /licensing/CERTIFICATE_OF_COMPETENCY/apply and
|
||||||
element: (
|
// /licensing/CERTIFICATE_OF_PROFICIENCY/apply, wired below.
|
||||||
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
|
||||||
<CoCApplicationPage />
|
|
||||||
</RequirePermission>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export const mockApplications: Record<string, any> = {
|
|||||||
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||||
applicantUserId: 'user-mock-001',
|
applicantUserId: 'user-mock-001',
|
||||||
kind: 'RENEWAL',
|
kind: 'RENEWAL',
|
||||||
status: 'ELIGIBILITY_APPROVED',
|
status: 'ELIGIBILITY_PAID',
|
||||||
assignedOfficerId: 'officer-mock-002',
|
assignedOfficerId: 'officer-mock-002',
|
||||||
claimedAt: '2026-08-01T10:00:00.000Z',
|
claimedAt: '2026-08-01T10:00:00.000Z',
|
||||||
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import type {
|
|||||||
ApplicationPayment,
|
ApplicationPayment,
|
||||||
ApplicationStaff,
|
ApplicationStaff,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
DocumentRequirement,
|
||||||
|
FormSchemaPalette,
|
||||||
|
FormSectionConfig,
|
||||||
InitiatePaymentResult,
|
InitiatePaymentResult,
|
||||||
IssuedLicense,
|
IssuedLicense,
|
||||||
Inspection,
|
Inspection,
|
||||||
@@ -25,6 +28,7 @@ import type {
|
|||||||
QueueFilter,
|
QueueFilter,
|
||||||
RemarkTargetType,
|
RemarkTargetType,
|
||||||
SavedQueueView,
|
SavedQueueView,
|
||||||
|
SchemaIssue,
|
||||||
TemplateFieldPlacement,
|
TemplateFieldPlacement,
|
||||||
TemplateLogoPlacement,
|
TemplateLogoPlacement,
|
||||||
TemplatePageOptions,
|
TemplatePageOptions,
|
||||||
@@ -66,6 +70,7 @@ const TAGS = [
|
|||||||
'License',
|
'License',
|
||||||
'SavedView',
|
'SavedView',
|
||||||
'LicenseTemplate',
|
'LicenseTemplate',
|
||||||
|
'DocumentRequirement',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) 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)],
|
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
|
// -------------------------------------------------------- application
|
||||||
createApplication: builder.mutation<
|
createApplication: builder.mutation<
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
@@ -494,9 +569,26 @@ export const licensingApi = baseApi
|
|||||||
scheduleExam: builder.mutation<
|
scheduleExam: builder.mutation<
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
|
{ 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 }) => ({
|
query: ({ id, ...body }) => ({
|
||||||
url: `/license-application-review/${id}/schedule-exam`,
|
url: `/license-application-review/${id}/exam-outcome`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
@@ -505,12 +597,13 @@ export const licensingApi = baseApi
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raises the examination fee — after eligibility approval, or again when
|
* A failed candidate asks for another sitting. Re-opens the examination
|
||||||
* a failed candidate elects to resit.
|
* 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) => ({
|
query: (id) => ({
|
||||||
url: `/license-applications/${id}/request-exam-payment`,
|
url: `/license-applications/${id}/retake`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, error, id) =>
|
invalidatesTags: (_r, error, id) =>
|
||||||
@@ -809,6 +902,13 @@ export const {
|
|||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetLicenseCategoriesQuery,
|
useGetLicenseCategoriesQuery,
|
||||||
useUpdateLicenseFeesMutation,
|
useUpdateLicenseFeesMutation,
|
||||||
|
useUpdateFormSchemaMutation,
|
||||||
|
useValidateFormSchemaMutation,
|
||||||
|
useGetFormSchemaPaletteQuery,
|
||||||
|
useGetDocumentRequirementsQuery,
|
||||||
|
useCreateDocumentRequirementMutation,
|
||||||
|
useUpdateDocumentRequirementMutation,
|
||||||
|
useDeleteDocumentRequirementMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
useGetLicenseTypeRequirementsQuery,
|
useGetLicenseTypeRequirementsQuery,
|
||||||
useCreateApplicationMutation,
|
useCreateApplicationMutation,
|
||||||
@@ -864,7 +964,8 @@ export const {
|
|||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useRejectApplicationMutation,
|
useRejectApplicationMutation,
|
||||||
useScheduleExamMutation,
|
useScheduleExamMutation,
|
||||||
useRequestExamPaymentMutation,
|
useRecordExamOutcomeMutation,
|
||||||
|
useRetakeExamMutation,
|
||||||
useConfirmPaymentMutation,
|
useConfirmPaymentMutation,
|
||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
useIssueCertificateMutation,
|
useIssueCertificateMutation,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
import { resolveTokenFromStorage } from '../../session';
|
import { resolveTokenFromStorage } from '../../session';
|
||||||
import type {
|
import type {
|
||||||
Bilingual,
|
Bilingual,
|
||||||
@@ -74,7 +75,8 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
|||||||
SCHEDULED: 'Pickup Scheduled',
|
SCHEDULED: 'Pickup Scheduled',
|
||||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||||
COMPLETED: 'Completed',
|
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_PAYMENT_PENDING: 'Exam Fee Due',
|
||||||
EXAM_PAID: 'Awaiting Exam Date',
|
EXAM_PAID: 'Awaiting Exam Date',
|
||||||
EXAM_SCHEDULED: 'Exam Scheduled',
|
EXAM_SCHEDULED: 'Exam Scheduled',
|
||||||
@@ -100,7 +102,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
|||||||
SCHEDULED: 'cyan',
|
SCHEDULED: 'cyan',
|
||||||
CERTIFICATE_ISSUED: 'green',
|
CERTIFICATE_ISSUED: 'green',
|
||||||
COMPLETED: 'green',
|
COMPLETED: 'green',
|
||||||
ELIGIBILITY_APPROVED: 'teal',
|
ELIGIBILITY_PAYMENT_PENDING: 'yellow',
|
||||||
|
ELIGIBILITY_PAID: 'lime',
|
||||||
EXAM_PAYMENT_PENDING: 'yellow',
|
EXAM_PAYMENT_PENDING: 'yellow',
|
||||||
EXAM_PAID: 'lime',
|
EXAM_PAID: 'lime',
|
||||||
EXAM_SCHEDULED: 'cyan',
|
EXAM_SCHEDULED: 'cyan',
|
||||||
@@ -135,7 +138,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
|||||||
REJECTED: 100,
|
REJECTED: 100,
|
||||||
// The exam leg sits between approval and the certificate fee, so these
|
// The exam leg sits between approval and the certificate fee, so these
|
||||||
// interleave with PAYMENT_PENDING (80) rather than running past it.
|
// 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_PAYMENT_PENDING: 64,
|
||||||
EXAM_PAID: 68,
|
EXAM_PAID: 68,
|
||||||
EXAM_SCHEDULED: 72,
|
EXAM_SCHEDULED: 72,
|
||||||
@@ -149,10 +153,15 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
|||||||
'DRAFT',
|
'DRAFT',
|
||||||
'RESUBMIT_REQUIRED',
|
'RESUBMIT_REQUIRED',
|
||||||
'PAYMENT_PENDING',
|
'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
|
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
||||||
// sit again after a failure.
|
// sit again after a failure.
|
||||||
'EXAM_PAYMENT_PENDING',
|
'EXAM_PAYMENT_PENDING',
|
||||||
'EXAM_FAILED',
|
'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. */
|
/** Statuses that are finished, whichever way they went. */
|
||||||
@@ -526,6 +535,13 @@ export function validateSections(
|
|||||||
}
|
}
|
||||||
if (empty) continue;
|
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);
|
const numeric = Number(value);
|
||||||
if (!Number.isNaN(numeric)) {
|
if (!Number.isNaN(numeric)) {
|
||||||
if (field.min !== undefined && numeric < field.min) {
|
if (field.min !== undefined && numeric < field.min) {
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ export type LicenseStatus =
|
|||||||
| "SCHEDULED"
|
| "SCHEDULED"
|
||||||
| "CERTIFICATE_ISSUED"
|
| "CERTIFICATE_ISSUED"
|
||||||
| "COMPLETED"
|
| "COMPLETED"
|
||||||
// Examined certificates (CoC, some CoP): approval establishes eligibility,
|
// Examined certificates (CoC, some CoP): the eligibility assessment fee is
|
||||||
// the candidate pays to sit, and the certificate fee falls due on a pass.
|
// due before review starts, then the candidate pays to sit, and the
|
||||||
| "ELIGIBILITY_APPROVED"
|
// certificate fee falls due on a pass.
|
||||||
|
| "ELIGIBILITY_PAYMENT_PENDING"
|
||||||
|
| "ELIGIBILITY_PAID"
|
||||||
| "EXAM_PAYMENT_PENDING"
|
| "EXAM_PAYMENT_PENDING"
|
||||||
| "EXAM_PAID"
|
| "EXAM_PAID"
|
||||||
| "EXAM_SCHEDULED"
|
| "EXAM_SCHEDULED"
|
||||||
@@ -77,10 +79,12 @@ export interface FormFieldConfig {
|
|||||||
label: Bilingual;
|
label: Bilingual;
|
||||||
type: FormFieldType;
|
type: FormFieldType;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
placeholder?: Bilingual;
|
||||||
helpText?: Bilingual;
|
helpText?: Bilingual;
|
||||||
options?: { value: string; label: Bilingual }[];
|
options?: { value: string; label: Bilingual }[];
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
|
maxLength?: number;
|
||||||
showWhen?: FieldCondition;
|
showWhen?: FieldCondition;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -233,6 +237,7 @@ export interface StcwCapacityRow {
|
|||||||
|
|
||||||
export interface DocumentRequirement {
|
export interface DocumentRequirement {
|
||||||
id: string;
|
id: string;
|
||||||
|
licenseTypeId: string;
|
||||||
key: string;
|
key: string;
|
||||||
name: Bilingual;
|
name: Bilingual;
|
||||||
description?: Bilingual;
|
description?: Bilingual;
|
||||||
@@ -242,7 +247,32 @@ export interface DocumentRequirement {
|
|||||||
allowedMimeTypes: string[];
|
allowedMimeTypes: string[];
|
||||||
maxSizeMb: number;
|
maxSizeMb: number;
|
||||||
requiresValidityDates: boolean;
|
requiresValidityDates: boolean;
|
||||||
|
allowMultiple: boolean;
|
||||||
sortOrder: number;
|
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 {
|
export interface StaffEvidenceRequirement {
|
||||||
|
|||||||
@@ -23,11 +23,25 @@ import { z } from 'zod';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||||
|
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||||
import { AuthShell } from '../components/AuthShell';
|
import { AuthShell } from '../components/AuthShell';
|
||||||
import { useAuthConfig } from '../AuthConfig';
|
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({
|
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>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
@@ -156,8 +170,8 @@ export function ForgotPasswordPage() {
|
|||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Title>
|
</Title>
|
||||||
<Text c="dimmed" mt={6}>
|
<Text c="dimmed" mt={6}>
|
||||||
Enter the email linked to your account and we'll send you a link
|
Enter the email or phone number linked to your account and
|
||||||
to reset your password.
|
we'll send you a link to reset your password.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,7 +184,7 @@ export function ForgotPasswordPage() {
|
|||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email address"
|
label="Email or phone"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
size="md"
|
size="md"
|
||||||
leftSection={<IconMail size={18} />}
|
leftSection={<IconMail size={18} />}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useDispatch } from "react-redux";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useApiMutation } from "@ema-platform/api";
|
import { useApiMutation } from "@ema-platform/api";
|
||||||
import { notify, useErrorHandler } from "@ema-platform/ui";
|
import { notify, useErrorHandler } from "@ema-platform/ui";
|
||||||
|
import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
|
||||||
import { AuthShell } from "../components/AuthShell";
|
import { AuthShell } from "../components/AuthShell";
|
||||||
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
|
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
|
||||||
import type {
|
import type {
|
||||||
@@ -53,11 +54,7 @@ export function LoginPage() {
|
|||||||
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (window.history.length > 1) {
|
navigate("/");
|
||||||
navigate(-1);
|
|
||||||
} else {
|
|
||||||
navigate("/");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Built inside the component (not module scope) so validation messages
|
// Built inside the component (not module scope) so validation messages
|
||||||
@@ -67,24 +64,23 @@ export function LoginPage() {
|
|||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.transform((value) => {
|
.transform((value) => {
|
||||||
// Convert 09xxxxxxxx -> +2519xxxxxxxx
|
// A phone-looking value normalizes to E.164 (bare Ethiopian
|
||||||
if (/^09\d{8}$/.test(value)) {
|
// national numbers, e.g. 09xxxxxxxx, default to +251) so the
|
||||||
return `+251${value.substring(1)}`;
|
// international check below can validate it; anything else
|
||||||
}
|
// (an email) passes through untouched.
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
return value;
|
if (emailRegex.test(value)) return value;
|
||||||
|
return parsePhoneNumberFromString(value, "ET")?.number ?? value;
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(value) => {
|
(value) => {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
const phoneRegex = /^\+2519\d{8}$/;
|
return emailRegex.test(value) || isValidPhoneNumber(value);
|
||||||
|
|
||||||
return emailRegex.test(value) || phoneRegex.test(value);
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: t(
|
message: t(
|
||||||
"login.emailOrPhoneInvalid",
|
"login.emailOrPhoneInvalid",
|
||||||
"Enter a valid email or phone number (+2519xxxxxxxx)",
|
"Enter a valid email or phone number",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
IconArrowLeft,
|
IconArrowLeft,
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconAt,
|
IconAt,
|
||||||
IconDeviceMobile,
|
|
||||||
IconLock,
|
IconLock,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconUser,
|
IconUser,
|
||||||
@@ -29,7 +28,7 @@ import { useNavigate, Link } from 'react-router-dom';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
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 { AuthShell } from '../components/AuthShell';
|
||||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||||
import type { AuthUser } from '../types/auth.types';
|
import type { AuthUser } from '../types/auth.types';
|
||||||
@@ -88,7 +87,7 @@ export function SignupPage() {
|
|||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
|
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'),
|
userType: z.literal('individual'),
|
||||||
nameEn: z
|
nameEn: z
|
||||||
.string()
|
.string()
|
||||||
@@ -111,6 +110,8 @@ export function SignupPage() {
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
|
setValue,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -247,12 +248,13 @@ export function SignupPage() {
|
|||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label={t('signup.phoneLabel', 'Phone number')}
|
label={t('signup.phoneLabel', 'Phone number')}
|
||||||
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
|
placeholder={t('signup.phonePlaceholder', '9XX XXX XXX')}
|
||||||
leftSection={<IconDeviceMobile size={18} />}
|
value={watch('phoneNumber') || ''}
|
||||||
|
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
|
||||||
|
onBlur={() => trigger('phoneNumber')}
|
||||||
error={errors.phoneNumber?.message}
|
error={errors.phoneNumber?.message}
|
||||||
{...register('phoneNumber')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export interface CurrentProfile {
|
|||||||
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
|
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
|
||||||
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
|
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
|
||||||
seafarerStatusReason?: string | 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;
|
user: AuthUser;
|
||||||
address: CurrentProfileAddress;
|
address: CurrentProfileAddress;
|
||||||
profession: CurrentProfileProfession;
|
profession: CurrentProfileProfession;
|
||||||
|
|||||||
39
libs/auth/src/lib/utils/jwt.spec.ts
Normal file
39
libs/auth/src/lib/utils/jwt.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ export * from "./lib/layout/LanguageSwitcher";
|
|||||||
export * from "./lib/layout/PageHeader";
|
export * from "./lib/layout/PageHeader";
|
||||||
export * from "./lib/input/PasswordRequirements";
|
export * from "./lib/input/PasswordRequirements";
|
||||||
export * from "./lib/input/CountrySelect";
|
export * from "./lib/input/CountrySelect";
|
||||||
|
export * from "./lib/input/PhoneInput";
|
||||||
export * from "./lib/input/phone";
|
export * from "./lib/input/phone";
|
||||||
export * from "./lib/data/AdvancedTable";
|
export * from "./lib/data/AdvancedTable";
|
||||||
export * from "./lib/feedback/use-error-handler";
|
export * from "./lib/feedback/use-error-handler";
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ registerLocale(en);
|
|||||||
registerLocale(am);
|
registerLocale(am);
|
||||||
registerNationalityLocale(nationalityEn);
|
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';
|
return lng === 'am' ? 'am' : 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export function getNationalityName(code: string | null | undefined, lang: Countr
|
|||||||
return getCountryName(code, lang);
|
return getCountryName(code, lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CountryFlag({ code }: { code: string }) {
|
export function CountryFlag({ code }: { code: string }) {
|
||||||
const Flag = Flags[code as keyof typeof Flags];
|
const Flag = Flags[code as keyof typeof Flags];
|
||||||
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
|
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
|
||||||
}
|
}
|
||||||
|
|||||||
219
libs/ui/src/lib/input/PhoneInput.tsx
Normal file
219
libs/ui/src/lib/input/PhoneInput.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
libs/ui/src/lib/input/phone.spec.ts
Normal file
131
libs/ui/src/lib/input/phone.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,88 @@
|
|||||||
import { z } from 'zod';
|
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()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
|
.transform((v) => parsePhoneNumberFromString(v, 'ET')?.number ?? v)
|
||||||
.refine((v) => /^\+2519\d{8}$/.test(v), {
|
.superRefine((v, ctx) => {
|
||||||
message: 'Enter a valid phone number (+2519xxxxxxxx)',
|
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. */
|
/** 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
11
libs/ui/vite.config.mts
Normal 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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"i18n-nationality": "^1.4.0",
|
"i18n-nationality": "^1.4.0",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"js-cookie": "^3.0.8",
|
"js-cookie": "^3.0.8",
|
||||||
|
"libphonenumber-js": "^1.13.11",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -77,6 +77,9 @@ importers:
|
|||||||
js-cookie:
|
js-cookie:
|
||||||
specifier: ^3.0.8
|
specifier: ^3.0.8
|
||||||
version: 3.0.8
|
version: 3.0.8
|
||||||
|
libphonenumber-js:
|
||||||
|
specifier: ^1.13.11
|
||||||
|
version: 1.13.11
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
@@ -4837,6 +4840,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
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:
|
lightningcss-android-arm64@1.32.0:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -12006,6 +12012,8 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
libphonenumber-js@1.13.11: {}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
canvas: set this to true or false
|
||||||
core-js: set this to true or false
|
core-js: set this to true or false
|
||||||
esbuild: set this to true or false
|
esbuild: set this to true or false
|
||||||
nx: set this to true or false
|
nx: set this to true or false
|
||||||
|
|||||||
Reference in New Issue
Block a user