mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12: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'
|
||||
| 'reject'
|
||||
| 'schedule-exam'
|
||||
| 'record-exam-outcome'
|
||||
| 'confirm-payment'
|
||||
| 'schedule-issuance'
|
||||
| 'issue-certificate'
|
||||
@@ -68,7 +69,9 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
id: 'claim',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.claim',
|
||||
from: ['SUBMITTED'],
|
||||
// Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP)
|
||||
// sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED.
|
||||
from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
|
||||
permissions: ['can:claim:license-application'],
|
||||
emphasis: 'light',
|
||||
},
|
||||
@@ -198,7 +201,21 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
// Only after the examination fee clears — scheduling an unpaid candidate
|
||||
// is what the EXAM_PAID gate exists to prevent.
|
||||
from: ['EXAM_PAID'],
|
||||
permissions: ['can:schedule:exam-candidate'],
|
||||
// Matches the controller's guard on `:id/exam-scheduled`
|
||||
// (`LICENSE_PERMISSIONS.MANAGE_EXAMS`) — the previous string didn't
|
||||
// correspond to any real permission constant, so this button could never
|
||||
// actually be granted to anyone.
|
||||
permissions: ['can:manage:exams'],
|
||||
emphasis: 'filled',
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
id: 'record-exam-outcome',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.recordExamOutcome',
|
||||
// Only once the candidate has actually sat the exam.
|
||||
from: ['EXAM_SCHEDULED'],
|
||||
permissions: ['can:publish:exam-result'],
|
||||
emphasis: 'filled',
|
||||
color: 'cyan',
|
||||
},
|
||||
|
||||
@@ -22,7 +22,11 @@ export function licenseQueueActionsColumn(
|
||||
cell: ({ row }) =>
|
||||
handlers.claimable !== false &&
|
||||
row.original.assignedOfficerId === null &&
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
// Mirrors the CLAIM transition's `from` list: an examined cert
|
||||
// (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears,
|
||||
// not SUBMITTED.
|
||||
(row.original.status === "SUBMITTED" ||
|
||||
row.original.status === "ELIGIBILITY_PAID") ? (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
|
||||
@@ -87,6 +87,17 @@ const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
|
||||
"INSPECTION_COMPLETED",
|
||||
];
|
||||
|
||||
/** Statuses only an examined cert (CoC, or a CoP with requiresExamination) reaches. */
|
||||
const EXAM_ONLY_STATUSES: LicenseStatus[] = [
|
||||
"ELIGIBILITY_PAYMENT_PENDING",
|
||||
"ELIGIBILITY_PAID",
|
||||
"EXAM_PAYMENT_PENDING",
|
||||
"EXAM_PAID",
|
||||
"EXAM_SCHEDULED",
|
||||
"EXAM_PASSED",
|
||||
"EXAM_FAILED",
|
||||
];
|
||||
|
||||
const ALL_STATUSES: LicenseStatus[] = [
|
||||
"SUBMITTED",
|
||||
"UNDER_REVIEW",
|
||||
@@ -96,9 +107,11 @@ const ALL_STATUSES: LicenseStatus[] = [
|
||||
"INSPECTION_COMPLETED",
|
||||
"ON_HOLD",
|
||||
"APPROVED",
|
||||
...EXAM_ONLY_STATUSES,
|
||||
"PAYMENT_PENDING",
|
||||
"PAID",
|
||||
"PAYMENT_CONFIRMED",
|
||||
"SCHEDULED",
|
||||
"CERTIFICATE_ISSUED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
@@ -117,6 +130,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
||||
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
||||
return type.inspectionRequired;
|
||||
}
|
||||
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
||||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useScheduleExamMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
@@ -209,6 +210,7 @@ export function LicenseReviewPage() {
|
||||
const [issueCertificate] = useIssueCertificateMutation();
|
||||
const [scheduleExam, { isLoading: schedulingExam }] =
|
||||
useScheduleExamMutation();
|
||||
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
||||
const [holdApplication] = useHoldApplicationMutation();
|
||||
const [resumeApplication] = useResumeApplicationMutation();
|
||||
const [escalateApplication] = useEscalateApplicationMutation();
|
||||
@@ -234,6 +236,8 @@ export function LicenseReviewPage() {
|
||||
const [issuanceDate, setIssuanceDate] = useState("");
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||
const [examScore, setExamScore] = useState<number | undefined>();
|
||||
const [findings, setFindings] = useState("");
|
||||
const [checklist, setChecklist] = useState<
|
||||
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
||||
@@ -490,6 +494,11 @@ export function LicenseReviewPage() {
|
||||
case "schedule-exam":
|
||||
setScheduleExamOpen(true);
|
||||
return;
|
||||
// Pass/fail plus an optional score, same reasoning as schedule-exam:
|
||||
// needs its own inputs before anything is sent.
|
||||
case "record-exam-outcome":
|
||||
setExamOutcomeOpen(true);
|
||||
return;
|
||||
case "copy-link":
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
notifications.show({
|
||||
@@ -1173,6 +1182,73 @@ export function LicenseReviewPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={examOutcomeOpen}
|
||||
onClose={() => setExamOutcomeOpen(false)}
|
||||
title={t("review.actions.recordExamOutcome", "Record exam outcome")}
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"review.examOutcome.intro",
|
||||
"Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.",
|
||||
)}
|
||||
</Text>
|
||||
<NumberInput
|
||||
label={t("review.examOutcome.score", "Score (optional)")}
|
||||
value={examScore}
|
||||
onChange={(v) => setExamScore(typeof v === "number" ? v : undefined)}
|
||||
min={0}
|
||||
/>
|
||||
<ModalFooter grow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="lg"
|
||||
aria-label={t("review.passed", "Passed")}
|
||||
onClick={() =>
|
||||
run(
|
||||
async () => {
|
||||
await recordExamOutcome({
|
||||
id,
|
||||
passed: true,
|
||||
score: examScore,
|
||||
}).unwrap();
|
||||
setExamOutcomeOpen(false);
|
||||
setExamScore(undefined);
|
||||
},
|
||||
t("review.done.examPassed", "Exam result recorded — passed"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="lg"
|
||||
aria-label={t("review.failed", "Failed")}
|
||||
onClick={() =>
|
||||
run(
|
||||
async () => {
|
||||
await recordExamOutcome({
|
||||
id,
|
||||
passed: false,
|
||||
score: examScore,
|
||||
}).unwrap();
|
||||
setExamOutcomeOpen(false);
|
||||
setExamScore(undefined);
|
||||
},
|
||||
t("review.done.examFailed", "Exam result recorded — not passed"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={inspectionOpen}
|
||||
onClose={() => setInspectionOpen(false)}
|
||||
|
||||
@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
@@ -126,9 +126,9 @@ export function ProfilePage() {
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.usernameRequired') }),
|
||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.phoneRequired') }),
|
||||
// Shared international rule: bare 09xxxxxxxx normalizes to +251, any
|
||||
// other E.164 number is accepted as typed.
|
||||
phoneNumber,
|
||||
});
|
||||
type ProfileValues = z.infer<typeof profileSchema>;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const am: Translations = {
|
||||
allApplications: "ሁሉም ማመልከቻዎች",
|
||||
licenceRegister: "የፈቃድ መዝገብ",
|
||||
certificateDesigner: "የምስክር ወረቀት ንድፍ",
|
||||
certificateRequirements: "የምስክር ወረቀት መስፈርቶች",
|
||||
byType: "በዓይነት",
|
||||
typeFreightForwarder: "የጭነት አስተላላፊ",
|
||||
typeShippingAgent: "የመርከብ ወኪል",
|
||||
@@ -1201,6 +1202,124 @@ export const am: Translations = {
|
||||
noPublishPermission: "ንድፎችን ማተም አይችሉም",
|
||||
},
|
||||
|
||||
certReq: {
|
||||
title: "የምስክር ወረቀት መስፈርቶች",
|
||||
subtitle:
|
||||
"ለፈቃድ ዓይነት አመልካቾች መሙላት ያለባቸውን የቅጽ መስኮች እና የሰነድ ሰቀላዎች ያዋቅሩ፣ መስፈርቱ መቼ ተግባራዊ እንደሚሆንም ጨምሮ።",
|
||||
licenseType: "የፈቃድ ዓይነት",
|
||||
selectLicenseType: "የፈቃድ ዓይነት ይምረጡ",
|
||||
loading: "የፈቃድ ዓይነቶችን በመጫን ላይ…",
|
||||
loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም",
|
||||
tabSchema: "የቅጽ ቅንብር",
|
||||
tabDocuments: "የሰነድ መስፈርቶች",
|
||||
cancel: "ይቅር",
|
||||
delete: "ሰርዝ",
|
||||
saveChanges: "ለውጦችን አስቀምጥ",
|
||||
actionFailed: "ተግባሩ አልተሳካም",
|
||||
condition: {
|
||||
title: "የሚታይበት ሁኔታ",
|
||||
badge: "ሁኔታዊ",
|
||||
enable: "ሁኔታ ሲሟላ ብቻ ተግባራዊ ይሁን",
|
||||
field: "የመስክ መንገድ",
|
||||
fieldHelp: "ወደ ቅጹ የነጥብ መንገድ፣ ለምሳሌ sectionKey.fieldKey",
|
||||
operator: "አመልካች",
|
||||
value: "ዋጋ",
|
||||
values: "ከእነዚህ አንዱ",
|
||||
removeValue: "ለማስወገድ ይጫኑ",
|
||||
isSet: "ተሞልቷል",
|
||||
isNotSet: "አልተሞላም",
|
||||
unknownField: "ይህ መንገድ በአሁኑ ቅንብር ውስጥ ያለ መስክ አይደለም — ቢሆንም እንደተጻፈው ይቀመጣል።",
|
||||
},
|
||||
schema: {
|
||||
subtitle:
|
||||
"አመልካቹ ለዚህ የፈቃድ ዓይነት የሚያየው ክፍሎች እና መስኮች። አንድ ቡድን የሚጋሩ ክፍሎች በአንድ የዊዛርድ ደረጃ ላይ አብረው ይታያሉ።",
|
||||
checkErrors: "ስህተቶችን ፈትሽ",
|
||||
noIssues: "ምንም ችግር አልተገኘም",
|
||||
issuesFound: "ችግሮች ተገኝተዋል",
|
||||
save: "ቅንብር አስቀምጥ",
|
||||
saved: "የቅጽ ቅንብር ተቀምጧል",
|
||||
empty: "እስካሁን ክፍል የለም",
|
||||
emptyBody: "ለዚህ የፈቃድ ዓይነት ቅጽ ለመገንባት ክፍል ይጨምሩ።",
|
||||
},
|
||||
section: {
|
||||
add: "ክፍል ጨምር",
|
||||
edit: "ክፍል አርትዕ",
|
||||
delete: "ክፍል ሰርዝ",
|
||||
deleteConfirm: '"{{name}}"ን እና ሁሉንም መስኮቹን ከዚህ ቅንብር ማስወገድ ይፈልጋሉ?',
|
||||
key: "የክፍል ቁልፍ",
|
||||
keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ",
|
||||
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
|
||||
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
|
||||
title: "ርዕስ",
|
||||
description: "መግለጫ",
|
||||
group: "የዊዛርድ ደረጃ ቡድን",
|
||||
groupHelp: "አንድ ቡድን የሚጋሩ ክፍሎች በአንድ ደረጃ ላይ አብረው ይታያሉ",
|
||||
groupBadge: "ቡድን",
|
||||
groupOrder: "የቡድን ቅደም ተከተል",
|
||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||
},
|
||||
field: {
|
||||
add: "መስክ ጨምር",
|
||||
edit: "መስክ አርትዕ",
|
||||
delete: "መስክ ሰርዝ",
|
||||
deleteConfirm: '"{{name}}"ን ከዚህ ክፍል ማስወገድ ይፈልጋሉ?',
|
||||
key: "የመስክ ቁልፍ",
|
||||
keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ — የቅጽ መረጃ ቁልፍ ይሆናል",
|
||||
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
|
||||
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
|
||||
label: "መለያ",
|
||||
type: "የመስክ ዓይነት",
|
||||
required: "የግድ ያስፈልጋል",
|
||||
placeholder: "ምሳሌ ጽሑፍ",
|
||||
helpText: "የእገዛ ጽሑፍ",
|
||||
min: "ዝቅተኛ",
|
||||
max: "ከፍተኛ",
|
||||
maxLength: "ከፍተኛ ርዝመት",
|
||||
options: "አማራጮች",
|
||||
addOption: "አማራጭ ጨምር",
|
||||
optionValue: "ዋጋ",
|
||||
optionLabel: "መለያ",
|
||||
},
|
||||
doc: {
|
||||
subtitle: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለበት፣ በአዲስ ማመልከቻ እና በዕድሳት የተከፈለ።",
|
||||
add: "የሰነድ መስፈርት ጨምር",
|
||||
edit: "የሰነድ መስፈርት አርትዕ",
|
||||
delete: "የሰነድ መስፈርት ሰርዝ",
|
||||
deleteWarning: "በዚህ ቦታ ላይ የሚተማመኑ አመልካቾች ከዚህ በኋላ አያዩትም። ይህ መመለስ አይቻልም።",
|
||||
deleteConfirm: '"{{name}}"ን ማስወገድ ይፈልጋሉ?',
|
||||
created: "የሰነድ መስፈርት ተጨምሯል",
|
||||
updated: "የሰነድ መስፈርት ተዘምኗል",
|
||||
deleted: "የሰነድ መስፈርት ተወግዷል",
|
||||
loading: "የሰነድ መስፈርቶችን በመጫን ላይ…",
|
||||
loadFailed: "የሰነድ መስፈርቶችን መጫን አልተቻለም",
|
||||
empty: "ምንም የሰነድ መስፈርት አልተዋቀረም",
|
||||
emptyBody: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለባቸውን ሰነዶች ይጨምሩ።",
|
||||
emptyKind: "ለዚህ የማመልከቻ ዓይነት እስካሁን የሰነድ መስፈርት የለም።",
|
||||
key: "ቁልፍ",
|
||||
keyHelp: "ይህን የሰነድ ቦታ የሚለይ ቋሚ መጠሪያ",
|
||||
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
|
||||
keyRequired: "ቁልፍ ያስፈልጋል",
|
||||
name: "ስም",
|
||||
description: "መግለጫ",
|
||||
applicationKind: "የማመልከቻ ዓይነት",
|
||||
kindNew: "አዲስ ማመልከቻ",
|
||||
kindRenewal: "ዕድሳት",
|
||||
kindLocked: "ከተፈጠረ በኋላ የማመልከቻ ዓይነት መቀየር አይቻልም",
|
||||
mode: "ዘዴ",
|
||||
modeAlways: "ሁልጊዜ ያስፈልጋል",
|
||||
modeConditional: "ሁኔታ ሲሟላ ያስፈልጋል",
|
||||
modeOptional: "አማራጭ ስቀላ",
|
||||
conditionRequired: "ሁኔታዊ መስፈርት ሁኔታ ያስፈልገዋል",
|
||||
allowedTypes: "የተፈቀዱ የፋይል ዓይነቶች",
|
||||
maxSize: "ከፍተኛ የፋይል መጠን (MB)",
|
||||
requiresValidity: "የቀን ገደብ ያስፈልጋል",
|
||||
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
|
||||
multiple: "ብዙ",
|
||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||
when: "መቼ",
|
||||
},
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
title: "የመርከበኞች መዝገብ",
|
||||
profileCount_one: "{{count}} መገለጫ",
|
||||
|
||||
@@ -32,6 +32,7 @@ export const en = {
|
||||
allApplications: 'All Applications',
|
||||
licenceRegister: 'Licence Register',
|
||||
certificateDesigner: 'Certificate Designer',
|
||||
certificateRequirements: 'Certificate Requirements',
|
||||
byType: 'By Type',
|
||||
typeFreightForwarder: 'Freight Forwarder',
|
||||
typeShippingAgent: 'Shipping Agent',
|
||||
@@ -1204,6 +1205,124 @@ export const en = {
|
||||
noPublishPermission: 'You cannot publish designs',
|
||||
},
|
||||
|
||||
certReq: {
|
||||
title: 'Certificate requirements',
|
||||
subtitle:
|
||||
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
||||
licenseType: 'Licence type',
|
||||
selectLicenseType: 'Select a licence type',
|
||||
loading: 'Loading licence types…',
|
||||
loadFailed: 'Could not load licence types',
|
||||
tabSchema: 'Form schema',
|
||||
tabDocuments: 'Document requirements',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
saveChanges: 'Save changes',
|
||||
actionFailed: 'Action failed',
|
||||
condition: {
|
||||
title: 'Visibility condition',
|
||||
badge: 'conditional',
|
||||
enable: 'Only apply when a condition holds',
|
||||
field: 'Field path',
|
||||
fieldHelp: 'Dot path into the form, e.g. sectionKey.fieldKey',
|
||||
operator: 'Operator',
|
||||
value: 'Value',
|
||||
values: 'Any of',
|
||||
removeValue: 'Click to remove',
|
||||
isSet: 'is set',
|
||||
isNotSet: 'is not set',
|
||||
unknownField: 'This path is not a field in the current schema yet — it will still be saved as typed.',
|
||||
},
|
||||
schema: {
|
||||
subtitle:
|
||||
'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.',
|
||||
checkErrors: 'Check for errors',
|
||||
noIssues: 'No issues found',
|
||||
issuesFound: 'Issues found',
|
||||
save: 'Save schema',
|
||||
saved: 'Form schema saved',
|
||||
empty: 'No sections yet',
|
||||
emptyBody: "Add a section to start building this licence type's form.",
|
||||
},
|
||||
section: {
|
||||
add: 'Add section',
|
||||
edit: 'Edit section',
|
||||
delete: 'Delete section',
|
||||
deleteConfirm: 'Remove "{{name}}" and all of its fields from this schema?',
|
||||
key: 'Section key',
|
||||
keyHelp: 'Letters, numbers and underscores only',
|
||||
keyLocked: 'Key cannot change once created',
|
||||
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
group: 'Wizard step group',
|
||||
groupHelp: 'Sections sharing the same group render together on one step',
|
||||
groupBadge: 'group',
|
||||
groupOrder: 'Group order',
|
||||
sortOrder: 'Sort order',
|
||||
},
|
||||
field: {
|
||||
add: 'Add field',
|
||||
edit: 'Edit field',
|
||||
delete: 'Delete field',
|
||||
deleteConfirm: 'Remove "{{name}}" from this section?',
|
||||
key: 'Field key',
|
||||
keyHelp: 'Letters, numbers and underscores only — becomes the form data key',
|
||||
keyLocked: 'Key cannot change once created',
|
||||
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
|
||||
label: 'Label',
|
||||
type: 'Field type',
|
||||
required: 'Required',
|
||||
placeholder: 'Placeholder',
|
||||
helpText: 'Help text',
|
||||
min: 'Minimum',
|
||||
max: 'Maximum',
|
||||
maxLength: 'Max length',
|
||||
options: 'Options',
|
||||
addOption: 'Add option',
|
||||
optionValue: 'Value',
|
||||
optionLabel: 'Label',
|
||||
},
|
||||
doc: {
|
||||
subtitle: 'What an applicant must upload for this licence type, split by new application and renewal.',
|
||||
add: 'Add document requirement',
|
||||
edit: 'Edit document requirement',
|
||||
delete: 'Delete document requirement',
|
||||
deleteWarning: 'Applicants already relying on this slot will no longer see it. This cannot be undone.',
|
||||
deleteConfirm: 'Remove "{{name}}"?',
|
||||
created: 'Document requirement added',
|
||||
updated: 'Document requirement updated',
|
||||
deleted: 'Document requirement removed',
|
||||
loading: 'Loading document requirements…',
|
||||
loadFailed: 'Could not load document requirements',
|
||||
empty: 'No document requirements configured',
|
||||
emptyBody: 'Add the documents an applicant must upload for this licence type.',
|
||||
emptyKind: 'No document requirements for this application kind yet.',
|
||||
key: 'Key',
|
||||
keyHelp: 'Stable slug identifying this document slot',
|
||||
keyLocked: 'Key cannot change once created',
|
||||
keyRequired: 'Key is required',
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
applicationKind: 'Application kind',
|
||||
kindNew: 'New application',
|
||||
kindRenewal: 'Renewal',
|
||||
kindLocked: 'Application kind cannot change once created',
|
||||
mode: 'Mode',
|
||||
modeAlways: 'Always required',
|
||||
modeConditional: 'Required when condition holds',
|
||||
modeOptional: 'Optional upload',
|
||||
conditionRequired: 'A conditional requirement needs a condition',
|
||||
allowedTypes: 'Allowed file types',
|
||||
maxSize: 'Max file size (MB)',
|
||||
requiresValidity: 'Requires validity dates',
|
||||
allowMultiple: 'Allow multiple uploads',
|
||||
multiple: 'multiple',
|
||||
sortOrder: 'Sort order',
|
||||
when: 'when',
|
||||
},
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
title: 'Seafarer registry',
|
||||
profileCount_one: '{{count}} profile',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconClipboardList,
|
||||
IconClipboardText,
|
||||
IconCreditCard,
|
||||
IconFileDescription,
|
||||
IconFilePlus,
|
||||
@@ -144,6 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
icon: IconRosetteDiscountCheck,
|
||||
permissions: [P.VIEW_TEMPLATES],
|
||||
},
|
||||
{
|
||||
to: '/certificate-requirements',
|
||||
label: 'nav.certificateRequirements',
|
||||
icon: IconClipboardText,
|
||||
permissions: [P.VIEW_LICENSE_TYPES],
|
||||
},
|
||||
{
|
||||
to: '/payment-config',
|
||||
label: 'nav.paymentConfig',
|
||||
|
||||
@@ -46,6 +46,7 @@ import { LicenseRegisterPage } from '../features/license-register/pages/LicenseR
|
||||
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
|
||||
|
||||
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||
@@ -114,6 +115,8 @@ const router = createBrowserRouter([
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
{ path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
|
||||
// Form schema + document requirement authoring, shared by every licence type.
|
||||
{ path: 'certificate-requirements', element: guard([P.VIEW_LICENSE_TYPES], <CertificateRequirementsPage />) },
|
||||
{ path: 'licence-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
|
||||
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
|
||||
// Deep link into the grid with the type facet pinned, so "Freight
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
@@ -55,6 +56,9 @@ interface CertificatesOverview {
|
||||
type: string;
|
||||
submitted: string;
|
||||
status: string;
|
||||
/** The fee owed at the current status, or null when nothing is due. */
|
||||
feeAmount: number | null;
|
||||
feeCurrency: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -72,7 +76,8 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'grape',
|
||||
INSPECTION_COMPLETED: 'grape',
|
||||
ELIGIBILITY_APPROVED: 'teal',
|
||||
ELIGIBILITY_PAYMENT_PENDING: 'orange',
|
||||
ELIGIBILITY_PAID: 'blue',
|
||||
EXAM_PAYMENT_PENDING: 'orange',
|
||||
EXAM_PAID: 'blue',
|
||||
EXAM_SCHEDULED: 'indigo',
|
||||
@@ -140,6 +145,7 @@ export function CertificatesPage() {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewTitle, setPreviewTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
|
||||
const { data } = useApiQuery<CertificatesOverview>({
|
||||
url: '/certificates/my',
|
||||
@@ -220,14 +226,25 @@ export function CertificatesPage() {
|
||||
{/* Tooltip needs a hoverable child even while the button itself is
|
||||
disabled, so the reason still shows on hover. */}
|
||||
<span>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/certificates/apply')}
|
||||
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
Apply for CoC / CoP
|
||||
Apply for CoC
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
Apply for CoP
|
||||
</Button>
|
||||
</Group>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -269,7 +286,7 @@ export function CertificatesPage() {
|
||||
<Text fw={700} mb="md">My Applications</Text>
|
||||
{applications.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
|
||||
No active CoC/CoP applications. Click "Apply for CoC" or "Apply for CoP" to start.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
@@ -301,6 +318,19 @@ export function CertificatesPage() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* feeAmount is non-null exactly when the server would
|
||||
accept a payment, so the button and the API agree. */}
|
||||
{app.feeAmount !== null && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="yellow"
|
||||
loading={isPaying}
|
||||
onClick={() => pay(app.applicationId)}
|
||||
>
|
||||
Pay {app.feeAmount.toLocaleString()} {app.feeCurrency}
|
||||
</Button>
|
||||
)}
|
||||
<Text
|
||||
fz="xs"
|
||||
c="blue"
|
||||
@@ -309,6 +339,7 @@ export function CertificatesPage() {
|
||||
>
|
||||
Details
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import {
|
||||
type FormSectionConfig,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
|
||||
@@ -223,6 +223,12 @@ export function ConfigDrivenSection({
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
) : field.type === 'PHONE' ? (
|
||||
<PhoneInput
|
||||
{...common}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
) : field.type === 'TEXTAREA' ? (
|
||||
<Textarea
|
||||
{...common}
|
||||
|
||||
@@ -144,6 +144,11 @@ export function DocumentSlots({
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{requirement.description && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{localized(requirement.description)}
|
||||
</Text>
|
||||
)}
|
||||
{existing?.files?.[0] && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{existing.files[0].originalName} ·{' '}
|
||||
|
||||
@@ -7,12 +7,13 @@ interface Props {
|
||||
t: TFunction;
|
||||
requesting: boolean;
|
||||
paying: boolean;
|
||||
onRequestExamFee: (app: LicenseApplication) => void;
|
||||
onRetakeExam: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the candidate can do while an examined certificate is in its exam leg.
|
||||
* What the candidate can do while an examined certificate is in its
|
||||
* eligibility or exam leg.
|
||||
*
|
||||
* Kept apart from the general actions column because these statuses only ever
|
||||
* occur on types that examine — folding them into that column would put five
|
||||
@@ -26,24 +27,50 @@ export function ExamStageActions({
|
||||
t,
|
||||
requesting,
|
||||
paying,
|
||||
onRequestExamFee,
|
||||
onRetakeExam,
|
||||
onPay,
|
||||
}: Props) {
|
||||
// Eligible but not yet committed to sitting, or sat and not passed: both are
|
||||
// the same decision — ask for the fee that buys a sitting.
|
||||
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
|
||||
const retake = app.status === 'EXAM_FAILED';
|
||||
// The eligibility fee is invoiced the moment the application is submitted —
|
||||
// there is no separate "request" step, so this is a pay button, exactly
|
||||
// like EXAM_PAYMENT_PENDING below.
|
||||
if (app.status === 'ELIGIBILITY_PAYMENT_PENDING') {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color={retake ? 'orange' : 'teal'}
|
||||
loading={requesting}
|
||||
onClick={() => onRequestExamFee(app)}
|
||||
color="yellow"
|
||||
loading={paying}
|
||||
onClick={() => onPay(app)}
|
||||
>
|
||||
{retake
|
||||
? t('applications.actions.bookRetake', 'Book a resit')
|
||||
: t('applications.actions.bookExam', 'Book exam')}
|
||||
{t('applications.actions.payEligibilityFee', {
|
||||
defaultValue: 'Pay eligibility fee ({{amount}} {{currency}})',
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Paid — queued for backoffice review. Nothing for the candidate to do.
|
||||
if (app.status === 'ELIGIBILITY_PAID') {
|
||||
return (
|
||||
<Button size="xs" variant="subtle" disabled>
|
||||
{t('applications.actions.eligibilityUnderReview', 'Under review')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Failed a sitting: the only decision left is whether to pay for another.
|
||||
if (app.status === 'EXAM_FAILED') {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="orange"
|
||||
loading={requesting}
|
||||
onClick={() => onRetakeExam(app)}
|
||||
>
|
||||
{t('applications.actions.bookRetake', 'Book a resit')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +93,25 @@ export function ExamStageActions({
|
||||
);
|
||||
}
|
||||
|
||||
// Passed: the certificate itself is the last fee on the application.
|
||||
if (app.status === 'EXAM_PASSED') {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="yellow"
|
||||
loading={paying}
|
||||
onClick={() => onPay(app)}
|
||||
>
|
||||
{t('applications.actions.payCertificateFee', {
|
||||
defaultValue: 'Pay certificate fee ({{amount}} {{currency}})',
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Paid and scheduled are both waiting states — nothing for the candidate to
|
||||
// do, so say so rather than offering a button that does nothing.
|
||||
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {
|
||||
|
||||
@@ -270,13 +270,18 @@ export function LicenseApplicationPage() {
|
||||
const nameFallback = accountName?.en
|
||||
? splitPersonName(accountName.en)
|
||||
: null;
|
||||
const firstName = profile.firstName || nameFallback?.firstName || "";
|
||||
const middleName = profile.middleName || nameFallback?.middleName || "";
|
||||
const lastName = profile.lastName || nameFallback?.lastName || "";
|
||||
const context = {
|
||||
user: accountUser ?? profile.user,
|
||||
profile: {
|
||||
...profile,
|
||||
firstName: profile.firstName || nameFallback?.firstName || "",
|
||||
middleName: profile.middleName || nameFallback?.middleName || "",
|
||||
lastName: profile.lastName || nameFallback?.lastName || "",
|
||||
firstName,
|
||||
middleName,
|
||||
lastName,
|
||||
// The profile has no single "full name" column — it's first/middle/last.
|
||||
fullName: [firstName, middleName, lastName].filter(Boolean).join(" "),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -291,7 +296,12 @@ export function LicenseApplicationPage() {
|
||||
const untouched =
|
||||
current === undefined || current === null || current === "";
|
||||
if (!field.readOnly && !untouched) continue;
|
||||
const value = readSourcePath(context, source);
|
||||
const raw = readSourcePath(context, source);
|
||||
// Profile dates arrive as ISO datetimes; a DATE field's picker wants
|
||||
// yyyy-MM-dd. Seafarer registration's `profile.dob` source hits the
|
||||
// same mismatch today — fixed once here rather than per config.
|
||||
const value =
|
||||
field.type === "DATE" && typeof raw === "string" ? raw.slice(0, 10) : raw;
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
if (current === value) continue;
|
||||
next[section.key] = { ...next[section.key], [field.key]: value };
|
||||
@@ -727,9 +737,14 @@ export function LicenseApplicationPage() {
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{index > 0 && <Divider mb="lg" />}
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb={section.description ? 4 : "sm"}>
|
||||
{localized(section.title)}
|
||||
</Text>
|
||||
{section.description && (
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{localized(section.description)}
|
||||
</Text>
|
||||
)}
|
||||
{locked && (
|
||||
<Alert
|
||||
color="gray"
|
||||
|
||||
@@ -8,11 +8,13 @@ import { ExamStageActions } from '../../components/ExamStageActions';
|
||||
|
||||
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
||||
const EXAM_STAGE_STATUSES = [
|
||||
'ELIGIBILITY_APPROVED',
|
||||
'ELIGIBILITY_PAYMENT_PENDING',
|
||||
'ELIGIBILITY_PAID',
|
||||
'EXAM_PAYMENT_PENDING',
|
||||
'EXAM_PAID',
|
||||
'EXAM_SCHEDULED',
|
||||
'EXAM_FAILED',
|
||||
'EXAM_PASSED',
|
||||
];
|
||||
|
||||
export function applicationActionsColumn(
|
||||
@@ -23,12 +25,12 @@ export function applicationActionsColumn(
|
||||
bypassEnabled: boolean;
|
||||
bypassing: boolean;
|
||||
isPaying: boolean;
|
||||
/** True while the exam fee is being raised for a booking or a resit. */
|
||||
/** True while a resit is being requested. */
|
||||
requestingExamFee: boolean;
|
||||
onBypass: (app: LicenseApplication) => void;
|
||||
onCertificate: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
onRequestExamFee: (app: LicenseApplication) => void;
|
||||
onRetakeExam: (app: LicenseApplication) => void;
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
@@ -46,14 +48,17 @@ export function applicationActionsColumn(
|
||||
t={t}
|
||||
requesting={deps.requestingExamFee}
|
||||
paying={deps.isPaying}
|
||||
onRequestExamFee={deps.onRequestExamFee}
|
||||
onRetakeExam={deps.onRetakeExam}
|
||||
onPay={deps.onPay}
|
||||
/>
|
||||
{/* Both fee stops are bypassable — an examined certificate is
|
||||
{/* Every fee stop is bypassable — an examined certificate charges
|
||||
three separate fees (eligibility, exam, certificate) and is
|
||||
otherwise untestable without a live gateway. */}
|
||||
{deps.bypassEnabled &&
|
||||
(app.status === 'PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PAYMENT_PENDING') && (
|
||||
app.status === 'ELIGIBILITY_PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PASSED') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useRequestExamPaymentMutation,
|
||||
useRetakeExamMutation,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
@@ -99,8 +99,7 @@ export function MyApplicationsPage() {
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [requestExamPayment, { isLoading: requestingExamFee }] =
|
||||
useRequestExamPaymentMutation();
|
||||
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||
@@ -135,15 +134,15 @@ export function MyApplicationsPage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the examination fee, for a first sitting or a resit.
|
||||
* Re-opens the examination fee for a failed candidate.
|
||||
*
|
||||
* Payment is a separate step: this only moves the application to
|
||||
* Payment is a separate step: this only moves the application back to
|
||||
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
|
||||
* the provider the same way every other fee does.
|
||||
*/
|
||||
async function requestExamFee(applicationId: string) {
|
||||
async function retakeExamFee(applicationId: string) {
|
||||
try {
|
||||
await requestExamPayment(applicationId).unwrap();
|
||||
await retakeExam(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
||||
@@ -281,7 +280,7 @@ export function MyApplicationsPage() {
|
||||
onBypass: (app) => handleBypass(app.id),
|
||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||
onPay: (app) => pay(app.id),
|
||||
onRequestExamFee: (app) => requestExamFee(app.id),
|
||||
onRetakeExam: (app) => retakeExamFee(app.id),
|
||||
onOpen: (app) =>
|
||||
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFo
|
||||
import { z } from 'zod';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
|
||||
import { phoneNumber, optionalPhoneNumber, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
import { useGetLocationTypesQuery } from '../../location/api/location-api';
|
||||
import type { Location, LocationType } from '../../location/types/location';
|
||||
@@ -17,8 +17,8 @@ export function addressSchema(t: TFunction) {
|
||||
idNumber: z.string().trim().min(1, t('profileAddress.validation.idNumberRequired')),
|
||||
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
|
||||
nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
|
||||
primaryPhoneNumber: ethiopianPhone,
|
||||
secondaryPhoneNumber: optionalEthiopianPhone,
|
||||
primaryPhoneNumber: phoneNumber,
|
||||
secondaryPhoneNumber: optionalPhoneNumber,
|
||||
email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
|
||||
regionId: z.string().optional(),
|
||||
cityId: z.string().optional(),
|
||||
@@ -30,7 +30,7 @@ export function addressSchema(t: TFunction) {
|
||||
// Emergency contact is collected but never required — leaving it blank must
|
||||
// not stop an applicant moving on.
|
||||
emergencyContactName: z.string().trim().optional(),
|
||||
emergencyContactPhone: optionalEthiopianPhone,
|
||||
emergencyContactPhone: optionalPhoneNumber,
|
||||
emergencyContactRelation: z.string().trim().optional(),
|
||||
});
|
||||
}
|
||||
@@ -147,18 +147,22 @@ export function AddressFormContent({
|
||||
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
|
||||
error={errors.nationality?.message}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profileFields.primaryPhoneNumber')}
|
||||
description={t('profileAddress.accountManagedHint')}
|
||||
required
|
||||
readOnly
|
||||
{...register('primaryPhoneNumber')}
|
||||
value={watch('primaryPhoneNumber') || ''}
|
||||
onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: !!errors.primaryPhoneNumber })}
|
||||
onBlur={() => trigger('primaryPhoneNumber')}
|
||||
error={errors.primaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profileAddress.secondaryPhoneNumber')}
|
||||
placeholder={t('profileAddress.phonePlaceholder')}
|
||||
{...register('secondaryPhoneNumber')}
|
||||
value={watch('secondaryPhoneNumber') || ''}
|
||||
onChange={(val) => setValue('secondaryPhoneNumber', val, { shouldValidate: !!errors.secondaryPhoneNumber })}
|
||||
onBlur={() => trigger('secondaryPhoneNumber')}
|
||||
error={errors.secondaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -206,10 +210,12 @@ export function AddressFormContent({
|
||||
{...register('emergencyContactName')}
|
||||
error={errors.emergencyContactName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profileAddress.contactPhone')}
|
||||
placeholder={t('profileAddress.phonePlaceholder')}
|
||||
{...register('emergencyContactPhone')}
|
||||
value={watch('emergencyContactPhone') || ''}
|
||||
onChange={(val) => setValue('emergencyContactPhone', val, { shouldValidate: !!errors.emergencyContactPhone })}
|
||||
onBlur={() => trigger('emergencyContactPhone')}
|
||||
error={errors.emergencyContactPhone?.message}
|
||||
/>
|
||||
<TextInput
|
||||
|
||||
@@ -15,7 +15,12 @@ function isAtLeast18(dob: string): boolean {
|
||||
return birth <= cutoff;
|
||||
}
|
||||
|
||||
export const profileSchema = (t: TFunction) =>
|
||||
// Printed on the Seaman Book, so a seafarer account can't leave them blank —
|
||||
// every other account type may. Blood type offers UNKNOWN, so requiring an
|
||||
// answer never forces a claim. Mirrors seafarer-registration.seed-data.ts's
|
||||
// `required: true` on the same fields, and the backend's own check in
|
||||
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
|
||||
export const profileSchema = (t: TFunction, isSeafarer: boolean) =>
|
||||
z.object({
|
||||
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
|
||||
firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
|
||||
@@ -28,8 +33,32 @@ export const profileSchema = (t: TFunction) =>
|
||||
.refine((value) => isAtLeast18(value), {
|
||||
message: t('profileForm.validation.dobMinAge'),
|
||||
}),
|
||||
pob: z.string().optional(),
|
||||
pob: isSeafarer
|
||||
? z.string().min(1, t('profileForm.validation.pobRequired'))
|
||||
: z.string().optional(),
|
||||
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
|
||||
bloodType: isSeafarer
|
||||
? z.string().min(1, t('profileForm.validation.bloodTypeRequired'))
|
||||
: z.string().optional(),
|
||||
hairColor: isSeafarer
|
||||
? z.string().min(1, t('profileForm.validation.hairColorRequired'))
|
||||
: z.string().optional(),
|
||||
eyeColor: isSeafarer
|
||||
? z.string().min(1, t('profileForm.validation.eyeColorRequired'))
|
||||
: z.string().optional(),
|
||||
heightCm: isSeafarer
|
||||
? z
|
||||
.string()
|
||||
.min(1, t('profileForm.validation.heightRequired'))
|
||||
.refine((v) => Number(v) >= 100 && Number(v) <= 250, {
|
||||
message: t('profileForm.validation.heightRange'),
|
||||
})
|
||||
: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || (Number(v) >= 100 && Number(v) <= 250), {
|
||||
message: t('profileForm.validation.heightRange'),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
|
||||
@@ -37,6 +66,13 @@ export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
|
||||
export const GENDERS = ['MALE', 'FEMALE'] as const;
|
||||
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
|
||||
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
|
||||
// Must match EBloodType/EHairColor/EEyeColor on the backend (common/enums/user.enum.ts).
|
||||
export const BLOOD_TYPES = [
|
||||
'A_POSITIVE', 'A_NEGATIVE', 'B_POSITIVE', 'B_NEGATIVE',
|
||||
'AB_POSITIVE', 'AB_NEGATIVE', 'O_POSITIVE', 'O_NEGATIVE', 'UNKNOWN',
|
||||
] as const;
|
||||
export const HAIR_COLORS = ['BLACK', 'BROWN', 'BLONDE', 'RED', 'GREY', 'WHITE', 'BALD', 'OTHER'] as const;
|
||||
export const EYE_COLORS = ['BROWN', 'BLACK', 'BLUE', 'GREEN', 'HAZEL', 'GREY', 'OTHER'] as const;
|
||||
|
||||
interface ProfileFormContentProps {
|
||||
register: UseFormRegister<ProfileValues>;
|
||||
@@ -46,6 +82,8 @@ interface ProfileFormContentProps {
|
||||
trigger: UseFormTrigger<ProfileValues>;
|
||||
professionsLoading: boolean;
|
||||
professionOptions: Array<{ value: string; label: string }>;
|
||||
/** Place of birth, hair/eye colour and height become required for these accounts. */
|
||||
isSeafarer: boolean;
|
||||
}
|
||||
|
||||
export function ProfileFormContent({
|
||||
@@ -56,6 +94,7 @@ export function ProfileFormContent({
|
||||
trigger,
|
||||
professionsLoading,
|
||||
professionOptions,
|
||||
isSeafarer,
|
||||
}: ProfileFormContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -119,6 +158,7 @@ export function ProfileFormContent({
|
||||
<TextInput
|
||||
label={t('profileFields.pob')}
|
||||
placeholder={t('profileForm.placeholders.pob')}
|
||||
required={isSeafarer}
|
||||
{...register('pob')}
|
||||
error={errors.pob?.message}
|
||||
/>
|
||||
@@ -133,6 +173,50 @@ export function ProfileFormContent({
|
||||
onBlur={() => trigger('maritalStatus')}
|
||||
name="maritalStatus"
|
||||
/>
|
||||
<Select
|
||||
label={t('profileFields.bloodType')}
|
||||
placeholder={t('common.select')}
|
||||
required={isSeafarer}
|
||||
clearable={!isSeafarer}
|
||||
data={BLOOD_TYPES.map((b) => ({ value: b, label: t(`profileForm.bloodTypes.${b}`) }))}
|
||||
error={errors.bloodType?.message}
|
||||
value={watch('bloodType') || null}
|
||||
onChange={(val) => setValue('bloodType', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('bloodType')}
|
||||
name="bloodType"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profileFields.heightCm')}
|
||||
placeholder={t('profileForm.placeholders.heightCm')}
|
||||
type="number"
|
||||
required={isSeafarer}
|
||||
{...register('heightCm')}
|
||||
error={errors.heightCm?.message}
|
||||
/>
|
||||
<Select
|
||||
label={t('profileFields.hairColor')}
|
||||
placeholder={t('common.select')}
|
||||
required={isSeafarer}
|
||||
clearable={!isSeafarer}
|
||||
data={HAIR_COLORS.map((h) => ({ value: h, label: t(`profileForm.hairColors.${h}`) }))}
|
||||
error={errors.hairColor?.message}
|
||||
value={watch('hairColor') || null}
|
||||
onChange={(val) => setValue('hairColor', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('hairColor')}
|
||||
name="hairColor"
|
||||
/>
|
||||
<Select
|
||||
label={t('profileFields.eyeColor')}
|
||||
placeholder={t('common.select')}
|
||||
required={isSeafarer}
|
||||
clearable={!isSeafarer}
|
||||
data={EYE_COLORS.map((e) => ({ value: e, label: t(`profileForm.eyeColors.${e}`) }))}
|
||||
error={errors.eyeColor?.message}
|
||||
value={watch('eyeColor') || null}
|
||||
onChange={(val) => setValue('eyeColor', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('eyeColor')}
|
||||
name="eyeColor"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
IconBuildingWarehouse,
|
||||
IconMapPin,
|
||||
IconMoon,
|
||||
IconPhone,
|
||||
IconSettings,
|
||||
IconShieldLock,
|
||||
IconSun,
|
||||
@@ -49,7 +48,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
@@ -187,6 +186,12 @@ export function ProfilePage() {
|
||||
const showSeafarerBanner =
|
||||
can([PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]) &&
|
||||
!isReadyFor(SEAFARER_PROFILE_REQUIREMENT);
|
||||
// Place of birth, blood type, hair/eye colour and height print on the
|
||||
// Seaman Book, so a seafarer account can't leave them blank — every other
|
||||
// account type may. Mirrors the seafarer-registration wizard's
|
||||
// `required: true` on the same fields and the backend check in
|
||||
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
|
||||
const isSeafarer = (resolvedProfile ?? storedProfile)?.type === 'SEAFARER';
|
||||
|
||||
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
||||
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
|
||||
@@ -224,6 +229,10 @@ export function ProfilePage() {
|
||||
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
|
||||
pob: currentProfile.pob || '',
|
||||
maritalStatus: currentProfile.maritalStatus || '',
|
||||
bloodType: currentProfile.bloodType || '',
|
||||
hairColor: currentProfile.hairColor || '',
|
||||
eyeColor: currentProfile.eyeColor || '',
|
||||
heightCm: currentProfile.heightCm != null ? String(currentProfile.heightCm) : '',
|
||||
});
|
||||
|
||||
// Primary phone and email are the account's contact details (same
|
||||
@@ -270,7 +279,7 @@ export function ProfilePage() {
|
||||
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||
phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
|
||||
phoneNumber,
|
||||
});
|
||||
type PersonalValues = z.infer<typeof personalSchema>;
|
||||
|
||||
@@ -278,6 +287,9 @@ export function ProfilePage() {
|
||||
register: registerPersonal,
|
||||
handleSubmit: handlePersonalSubmit,
|
||||
reset: resetPersonal,
|
||||
watch: watchPersonal,
|
||||
setValue: setValuePersonal,
|
||||
trigger: triggerPersonal,
|
||||
formState: { errors: personalErrors },
|
||||
} = useForm<PersonalValues>({
|
||||
resolver: zodResolver(personalSchema),
|
||||
@@ -365,7 +377,7 @@ export function ProfilePage() {
|
||||
trigger: profileTriggerValidation,
|
||||
formState: { errors: profileErrors },
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema(t)),
|
||||
resolver: zodResolver(profileSchema(t, isSeafarer)),
|
||||
values: loadedProfile ?? undefined,
|
||||
});
|
||||
|
||||
@@ -383,7 +395,15 @@ export function ProfilePage() {
|
||||
await updateProfile({
|
||||
url: `/profiles/${profileId}`,
|
||||
method: 'PUT',
|
||||
body: values,
|
||||
// Empty string is not a valid enum value on the backend — an
|
||||
// untouched Select must clear the column, not fail validation.
|
||||
body: {
|
||||
...values,
|
||||
heightCm: values.heightCm ? Number(values.heightCm) : null,
|
||||
bloodType: values.bloodType || null,
|
||||
hairColor: values.hairColor || null,
|
||||
eyeColor: values.eyeColor || null,
|
||||
},
|
||||
}).unwrap();
|
||||
setLoadedProfile({ ...values, pob: values.pob ?? '' });
|
||||
|
||||
@@ -669,11 +689,12 @@ export function ProfilePage() {
|
||||
error={personalErrors.email?.message}
|
||||
{...registerPersonal('email')}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profile.fields.phone')}
|
||||
leftSection={<IconPhone size={18} />}
|
||||
value={watchPersonal('phoneNumber') || ''}
|
||||
onChange={(val) => setValuePersonal('phoneNumber', val, { shouldValidate: !!personalErrors.phoneNumber })}
|
||||
onBlur={() => triggerPersonal('phoneNumber')}
|
||||
error={personalErrors.phoneNumber?.message}
|
||||
{...registerPersonal('phoneNumber')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
@@ -724,6 +745,7 @@ export function ProfilePage() {
|
||||
trigger={profileTriggerValidation}
|
||||
professionsLoading={professionsLoading}
|
||||
professionOptions={professionOptions}
|
||||
isSeafarer={isSeafarer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
IconCheck,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconPhone,
|
||||
IconShip,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, PhoneInput } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
const OWNER_TYPES = [
|
||||
'Individual (Private Owner)',
|
||||
@@ -51,7 +51,7 @@ export function VesselOwnerRegisterPage() {
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [registerTrigger] = useApiMutation<{ id: string }>();
|
||||
|
||||
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
|
||||
const canSubmit = !!fullName.trim() && !!email.trim() && isValidPhoneNumber(phone) && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!canSubmit) {
|
||||
@@ -143,13 +143,11 @@ export function VesselOwnerRegisterPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label="Phone Number"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
leftSection={<IconPhone size={16} />}
|
||||
required
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.currentTarget.value)}
|
||||
onChange={setPhone}
|
||||
/>
|
||||
<TextInput
|
||||
label="National ID / TIN"
|
||||
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
IconTransferIn,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, PhoneInput } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
// Minimal vessel type for the approved vessel list
|
||||
interface ApprovedVessel {
|
||||
@@ -208,7 +209,7 @@ export function OwnershipTransferPage() {
|
||||
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
|
||||
|
||||
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
|
||||
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
|
||||
isValidPhoneNumber(newOwnerPhone) && !!transferReason && !!billOfSale;
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedVesselId(null);
|
||||
@@ -393,7 +394,7 @@ export function OwnershipTransferPage() {
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
|
||||
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
|
||||
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" required value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} />
|
||||
<PhoneInput label="Phone Number" required value={newOwnerPhone} onChange={setNewOwnerPhone} />
|
||||
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
|
||||
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
|
||||
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
IconShip,
|
||||
IconWaveSine,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, PhoneInput } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -311,7 +312,7 @@ export function VesselRegistrationApplicationPage() {
|
||||
if (active === 2) return (
|
||||
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
|
||||
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
|
||||
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
|
||||
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && isValidPhoneNumber(ownerPhone)
|
||||
);
|
||||
if (active === 3) return category === 'Inland Waterway Vessel'
|
||||
? !!files.vesselPhotos
|
||||
@@ -551,12 +552,11 @@ export function VesselRegistrationApplicationPage() {
|
||||
value={ownerNationalIdOrTin}
|
||||
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label="Owner Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
required
|
||||
value={ownerPhone}
|
||||
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
|
||||
onChange={setOwnerPhone}
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner Address"
|
||||
|
||||
@@ -258,6 +258,10 @@ export const am: Translations = {
|
||||
dob: 'የትውልድ ቀን',
|
||||
pob: 'የትውልድ ቦታ',
|
||||
maritalStatus: 'የጋብቻ ሁኔታ',
|
||||
bloodType: 'የደም አይነት',
|
||||
hairColor: 'የፀጉር ቀለም',
|
||||
eyeColor: 'የአይን ቀለም',
|
||||
heightCm: 'ቁመት (ሴ.ሜ)',
|
||||
professionId: 'ሙያ',
|
||||
idType: 'የመታወቂያ ዓይነት',
|
||||
idNumber: 'የመታወቂያ ቁጥር',
|
||||
@@ -436,6 +440,7 @@ export const am: Translations = {
|
||||
middleName: 'የአባት ስም ያስገቡ',
|
||||
lastName: 'የአያት ስም ያስገቡ',
|
||||
pob: 'ከተማ፣ ክልል',
|
||||
heightCm: 'ለምሳሌ 175',
|
||||
},
|
||||
genders: {
|
||||
MALE: 'ወንድ',
|
||||
@@ -447,6 +452,36 @@ export const am: Translations = {
|
||||
DIVORCED: 'የፈታ/ች',
|
||||
WIDOWED: 'የሞተበት/ባት',
|
||||
},
|
||||
bloodTypes: {
|
||||
A_POSITIVE: 'A+',
|
||||
A_NEGATIVE: 'A-',
|
||||
B_POSITIVE: 'B+',
|
||||
B_NEGATIVE: 'B-',
|
||||
AB_POSITIVE: 'AB+',
|
||||
AB_NEGATIVE: 'AB-',
|
||||
O_POSITIVE: 'O+',
|
||||
O_NEGATIVE: 'O-',
|
||||
UNKNOWN: 'የማይታወቅ',
|
||||
},
|
||||
hairColors: {
|
||||
BLACK: 'ጥቁር',
|
||||
BROWN: 'ቡናማ',
|
||||
BLONDE: 'ወርቃማ',
|
||||
RED: 'ቀይ',
|
||||
GREY: 'ግራጫ',
|
||||
WHITE: 'ነጭ',
|
||||
BALD: 'ራሰ በራ',
|
||||
OTHER: 'ሌላ',
|
||||
},
|
||||
eyeColors: {
|
||||
BROWN: 'ቡናማ',
|
||||
BLACK: 'ጥቁር',
|
||||
BLUE: 'ሰማያዊ',
|
||||
GREEN: 'አረንጓዴ',
|
||||
HAZEL: 'ኮክ ቡናማ',
|
||||
GREY: 'ግራጫ',
|
||||
OTHER: 'ሌላ',
|
||||
},
|
||||
validation: {
|
||||
professionRequired: 'ሙያዎን ይምረጡ',
|
||||
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
|
||||
@@ -457,6 +492,12 @@ export const am: Translations = {
|
||||
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
|
||||
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
|
||||
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
|
||||
heightRange: 'ቁመት ከ100 እስከ 250 ሴ.ሜ መሆን አለበት',
|
||||
pobRequired: 'የትውልድ ቦታዎን ያስገቡ',
|
||||
bloodTypeRequired: 'የደም አይነትዎን ይምረጡ — ካልተመረመሩ የማይታወቅ ይምረጡ',
|
||||
hairColorRequired: 'የፀጉር ቀለምዎን ይምረጡ',
|
||||
eyeColorRequired: 'የአይን ቀለምዎን ይምረጡ',
|
||||
heightRequired: 'ቁመትዎን ያስገቡ',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -542,7 +583,7 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ",
|
||||
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
||||
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
||||
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
||||
@@ -577,7 +618,7 @@ export const am: Translations = {
|
||||
usernameLabel: "የተጠቃሚ ስም",
|
||||
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
||||
phoneLabel: "ስልክ ቁጥር",
|
||||
phonePlaceholder: "+251 911 234 567",
|
||||
phonePlaceholder: "9XX XXX XXX",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
||||
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
||||
@@ -692,7 +733,7 @@ export const am: Translations = {
|
||||
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
|
||||
idTypePlaceholder: 'ይምረጡ',
|
||||
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
|
||||
phonePlaceholder: '+251 9XX XXX XXX',
|
||||
phonePlaceholder: '9XX XXX XXX',
|
||||
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
|
||||
postalAddressPlaceholder: 'ፖስታ ሳጥን',
|
||||
contactNamePlaceholder: 'ሙሉ ስም',
|
||||
|
||||
@@ -257,6 +257,10 @@ export const en = {
|
||||
dob: 'Date of birth',
|
||||
pob: 'Place of birth',
|
||||
maritalStatus: 'Marital status',
|
||||
bloodType: 'Blood type',
|
||||
hairColor: 'Hair color',
|
||||
eyeColor: 'Eye color',
|
||||
heightCm: 'Height (cm)',
|
||||
professionId: 'Profession',
|
||||
idType: 'ID type',
|
||||
idNumber: 'ID number',
|
||||
@@ -435,6 +439,7 @@ export const en = {
|
||||
middleName: 'Enter middle name',
|
||||
lastName: 'Enter last name',
|
||||
pob: 'City, Region',
|
||||
heightCm: 'e.g. 175',
|
||||
},
|
||||
genders: {
|
||||
MALE: 'Male',
|
||||
@@ -446,6 +451,36 @@ export const en = {
|
||||
DIVORCED: 'Divorced',
|
||||
WIDOWED: 'Widowed',
|
||||
},
|
||||
bloodTypes: {
|
||||
A_POSITIVE: 'A+',
|
||||
A_NEGATIVE: 'A-',
|
||||
B_POSITIVE: 'B+',
|
||||
B_NEGATIVE: 'B-',
|
||||
AB_POSITIVE: 'AB+',
|
||||
AB_NEGATIVE: 'AB-',
|
||||
O_POSITIVE: 'O+',
|
||||
O_NEGATIVE: 'O-',
|
||||
UNKNOWN: 'Unknown',
|
||||
},
|
||||
hairColors: {
|
||||
BLACK: 'Black',
|
||||
BROWN: 'Brown',
|
||||
BLONDE: 'Blonde',
|
||||
RED: 'Red',
|
||||
GREY: 'Grey',
|
||||
WHITE: 'White',
|
||||
BALD: 'Bald',
|
||||
OTHER: 'Other',
|
||||
},
|
||||
eyeColors: {
|
||||
BROWN: 'Brown',
|
||||
BLACK: 'Black',
|
||||
BLUE: 'Blue',
|
||||
GREEN: 'Green',
|
||||
HAZEL: 'Hazel',
|
||||
GREY: 'Grey',
|
||||
OTHER: 'Other',
|
||||
},
|
||||
validation: {
|
||||
professionRequired: 'Select your profession',
|
||||
firstNameMin: 'First name must be at least 3 characters',
|
||||
@@ -456,6 +491,12 @@ export const en = {
|
||||
dobMinAge: 'You must be at least 18 years old',
|
||||
maritalStatusRequired: 'Select your marital status',
|
||||
nameParts: 'Enter your first, middle, and last name',
|
||||
heightRange: 'Height must be between 100 and 250 cm',
|
||||
pobRequired: 'Enter your place of birth',
|
||||
bloodTypeRequired: 'Select your blood type — choose Unknown if untested',
|
||||
hairColorRequired: 'Select your hair color',
|
||||
eyeColorRequired: 'Select your eye color',
|
||||
heightRequired: 'Enter your height',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -541,7 +582,7 @@ export const en = {
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number',
|
||||
passwordMinLength: 'Password must be at least 8 characters',
|
||||
welcome: 'Welcome to {{appName}}',
|
||||
subtitle: 'Sign in to access your account.',
|
||||
@@ -576,7 +617,7 @@ export const en = {
|
||||
usernameLabel: 'Username',
|
||||
usernamePlaceholder: 'Choose a username',
|
||||
phoneLabel: 'Phone number',
|
||||
phonePlaceholder: '+251 911 234 567',
|
||||
phonePlaceholder: '9XX XXX XXX',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
confirmPasswordLabel: 'Confirm password',
|
||||
@@ -691,7 +732,7 @@ export const en = {
|
||||
accountManagedHint: 'From your account, edit it in the Personal tab',
|
||||
idTypePlaceholder: 'Select',
|
||||
idNumberPlaceholder: 'Enter ID number',
|
||||
phonePlaceholder: '+251 9XX XXX XXX',
|
||||
phonePlaceholder: '9XX XXX XXX',
|
||||
streetAddressPlaceholder: 'Street name, house number',
|
||||
postalAddressPlaceholder: 'P.O. Box',
|
||||
contactNamePlaceholder: 'Full name',
|
||||
|
||||
@@ -39,7 +39,6 @@ import { NotificationsPage } from "./features/notifications/pages/NotificationsP
|
||||
|
||||
// Phase 2 — CoC / CoP
|
||||
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||
@@ -69,6 +68,10 @@ export const router = createBrowserRouter([
|
||||
{ path: "/set-password", element: <SetPasswordPage /> },
|
||||
{ path: "/reset-password", element: <SetPasswordPage /> },
|
||||
|
||||
// Reached from the login page by a logged-out user, so it must stay
|
||||
// public — ProtectedRoute would bounce them straight to the landing page.
|
||||
{ path: "/forgot-password", element: <ForgotPasswordPage /> },
|
||||
|
||||
// Protected auth pages
|
||||
{
|
||||
element: (
|
||||
@@ -78,14 +81,6 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
path: "/otp-verify",
|
||||
},
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<ForgotPasswordPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
path: "/forgot-password",
|
||||
},
|
||||
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
|
||||
// profile details are collected where they are actually needed: on /profile,
|
||||
// via the dashboard nudge, or inline in an application flow. The path stays
|
||||
@@ -275,14 +270,9 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/certificates/apply",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
||||
<CoCApplicationPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
// CoC/CoP applications go through the generic license wizard —
|
||||
// /licensing/CERTIFICATE_OF_COMPETENCY/apply and
|
||||
// /licensing/CERTIFICATE_OF_PROFICIENCY/apply, wired below.
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
{
|
||||
|
||||
@@ -337,7 +337,7 @@ export const mockApplications: Record<string, any> = {
|
||||
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'RENEWAL',
|
||||
status: 'ELIGIBILITY_APPROVED',
|
||||
status: 'ELIGIBILITY_PAID',
|
||||
assignedOfficerId: 'officer-mock-002',
|
||||
claimedAt: '2026-08-01T10:00:00.000Z',
|
||||
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
ApplicationPayment,
|
||||
ApplicationStaff,
|
||||
Attachment,
|
||||
DocumentRequirement,
|
||||
FormSchemaPalette,
|
||||
FormSectionConfig,
|
||||
InitiatePaymentResult,
|
||||
IssuedLicense,
|
||||
Inspection,
|
||||
@@ -25,6 +28,7 @@ import type {
|
||||
QueueFilter,
|
||||
RemarkTargetType,
|
||||
SavedQueueView,
|
||||
SchemaIssue,
|
||||
TemplateFieldPlacement,
|
||||
TemplateLogoPlacement,
|
||||
TemplatePageOptions,
|
||||
@@ -66,6 +70,7 @@ const TAGS = [
|
||||
'License',
|
||||
'SavedView',
|
||||
'LicenseTemplate',
|
||||
'DocumentRequirement',
|
||||
] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
@@ -178,6 +183,76 @@ export const licensingApi = baseApi
|
||||
providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)],
|
||||
}),
|
||||
|
||||
// ------------------------------------------------ form-schema builder
|
||||
/** Replaces a licence type's form schema. Server re-validates on save. */
|
||||
updateFormSchema: builder.mutation<
|
||||
LicenseType,
|
||||
{ id: string; formSchema: { sections: FormSectionConfig[] } }
|
||||
>({
|
||||
query: ({ id, formSchema }) => ({
|
||||
url: `/license-types/${id}/form-schema`,
|
||||
method: 'PUT',
|
||||
body: { formSchema },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
|
||||
}),
|
||||
|
||||
/** Dry-run lint, for inline feedback while the schema is being edited. */
|
||||
validateFormSchema: builder.mutation<
|
||||
{ valid: boolean; issues: SchemaIssue[] },
|
||||
{ formSchema: { sections: FormSectionConfig[] }; licenseTypeId?: string }
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: '/license-types/form-schema/validate',
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
|
||||
/** Field types, condition operators and prefill sources the builder may offer. */
|
||||
getFormSchemaPalette: builder.query<FormSchemaPalette, void>({
|
||||
query: () => ({ url: '/license-types/form-schema/palette' }),
|
||||
}),
|
||||
|
||||
// ------------------------------------------------- document requirements
|
||||
/**
|
||||
* Every document requirement, for the admin editor to filter by licence
|
||||
* type client-side. The collection-query `q` filter syntax (`w=column:op:
|
||||
* value`) has no typed builder on this side, and the table is small
|
||||
* configuration data with no pagination need — see `licenseTypeId` usage
|
||||
* at the call site.
|
||||
*/
|
||||
getDocumentRequirements: builder.query<Paginated<DocumentRequirement>, void>({
|
||||
query: () => ({ url: '/document-requirements' }),
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
createDocumentRequirement: builder.mutation<
|
||||
DocumentRequirement,
|
||||
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
|
||||
>({
|
||||
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
}),
|
||||
|
||||
updateDocumentRequirement: builder.mutation<
|
||||
DocumentRequirement,
|
||||
{ id: string } & Partial<DocumentRequirement>
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/document-requirements/${id}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
}),
|
||||
|
||||
deleteDocumentRequirement: builder.mutation<unknown, string>({
|
||||
query: (id) => ({ url: `/document-requirements/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------- application
|
||||
createApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
@@ -494,9 +569,26 @@ export const licensingApi = baseApi
|
||||
scheduleExam: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
|
||||
>({
|
||||
query: ({ id, examDate: _examDate, ...body }) => ({
|
||||
// Matches the controller's `:id/exam-scheduled` route — `examDate`
|
||||
// is UI-only context for the confirmation toast, not part of
|
||||
// `MarkExamScheduledDto`, so it never goes on the wire.
|
||||
url: `/license-application-review/${id}/exam-scheduled`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/** Records a published examination result (pass or fail). */
|
||||
recordExamOutcome: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; passed: boolean; score?: number }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/schedule-exam`,
|
||||
url: `/license-application-review/${id}/exam-outcome`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
@@ -505,12 +597,13 @@ export const licensingApi = baseApi
|
||||
}),
|
||||
|
||||
/**
|
||||
* Raises the examination fee — after eligibility approval, or again when
|
||||
* a failed candidate elects to resit.
|
||||
* A failed candidate asks for another sitting. Re-opens the examination
|
||||
* fee (EXAM_FAILED -> EXAM_PAYMENT_PENDING); eligibility was already
|
||||
* assessed and paid for on the first attempt.
|
||||
*/
|
||||
requestExamPayment: builder.mutation<LicenseApplication, string>({
|
||||
retakeExam: builder.mutation<LicenseApplication, string>({
|
||||
query: (id) => ({
|
||||
url: `/license-applications/${id}/request-exam-payment`,
|
||||
url: `/license-applications/${id}/retake`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
@@ -809,6 +902,13 @@ export const {
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
useUpdateFormSchemaMutation,
|
||||
useValidateFormSchemaMutation,
|
||||
useGetFormSchemaPaletteQuery,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useCreateDocumentRequirementMutation,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useCreateApplicationMutation,
|
||||
@@ -864,7 +964,8 @@ export const {
|
||||
useFinalApproveMutation,
|
||||
useRejectApplicationMutation,
|
||||
useScheduleExamMutation,
|
||||
useRequestExamPaymentMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useRetakeExamMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
@@ -74,7 +75,8 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
SCHEDULED: 'Pickup Scheduled',
|
||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||
COMPLETED: 'Completed',
|
||||
ELIGIBILITY_APPROVED: 'Eligible to Sit',
|
||||
ELIGIBILITY_PAYMENT_PENDING: 'Eligibility Fee Due',
|
||||
ELIGIBILITY_PAID: 'Eligibility Under Review',
|
||||
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
|
||||
EXAM_PAID: 'Awaiting Exam Date',
|
||||
EXAM_SCHEDULED: 'Exam Scheduled',
|
||||
@@ -100,7 +102,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
SCHEDULED: 'cyan',
|
||||
CERTIFICATE_ISSUED: 'green',
|
||||
COMPLETED: 'green',
|
||||
ELIGIBILITY_APPROVED: 'teal',
|
||||
ELIGIBILITY_PAYMENT_PENDING: 'yellow',
|
||||
ELIGIBILITY_PAID: 'lime',
|
||||
EXAM_PAYMENT_PENDING: 'yellow',
|
||||
EXAM_PAID: 'lime',
|
||||
EXAM_SCHEDULED: 'cyan',
|
||||
@@ -135,7 +138,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
REJECTED: 100,
|
||||
// The exam leg sits between approval and the certificate fee, so these
|
||||
// interleave with PAYMENT_PENDING (80) rather than running past it.
|
||||
ELIGIBILITY_APPROVED: 60,
|
||||
ELIGIBILITY_PAYMENT_PENDING: 52,
|
||||
ELIGIBILITY_PAID: 56,
|
||||
EXAM_PAYMENT_PENDING: 64,
|
||||
EXAM_PAID: 68,
|
||||
EXAM_SCHEDULED: 72,
|
||||
@@ -149,10 +153,15 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
||||
'DRAFT',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'PAYMENT_PENDING',
|
||||
// Due the moment an examined application is submitted, before any officer
|
||||
// looks at it.
|
||||
'ELIGIBILITY_PAYMENT_PENDING',
|
||||
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
||||
// sit again after a failure.
|
||||
'EXAM_PAYMENT_PENDING',
|
||||
'EXAM_FAILED',
|
||||
// Passed: the certificate fee falls due, and only the candidate can pay it.
|
||||
'EXAM_PASSED',
|
||||
];
|
||||
|
||||
/** Statuses that are finished, whichever way they went. */
|
||||
@@ -526,6 +535,13 @@ export function validateSections(
|
||||
}
|
||||
if (empty) continue;
|
||||
|
||||
// PhoneInput emits E.164 while typing, so a half-typed "+2519" is a
|
||||
// non-empty string that still has to be caught here.
|
||||
if (field.type === 'PHONE' && !isValidPhoneNumber(String(value))) {
|
||||
errors[`${section.key}.${field.key}`] = 'Enter a valid phone number';
|
||||
continue;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
if (field.min !== undefined && numeric < field.min) {
|
||||
|
||||
@@ -41,9 +41,11 @@ export type LicenseStatus =
|
||||
| "SCHEDULED"
|
||||
| "CERTIFICATE_ISSUED"
|
||||
| "COMPLETED"
|
||||
// Examined certificates (CoC, some CoP): approval establishes eligibility,
|
||||
// the candidate pays to sit, and the certificate fee falls due on a pass.
|
||||
| "ELIGIBILITY_APPROVED"
|
||||
// Examined certificates (CoC, some CoP): the eligibility assessment fee is
|
||||
// due before review starts, then the candidate pays to sit, and the
|
||||
// certificate fee falls due on a pass.
|
||||
| "ELIGIBILITY_PAYMENT_PENDING"
|
||||
| "ELIGIBILITY_PAID"
|
||||
| "EXAM_PAYMENT_PENDING"
|
||||
| "EXAM_PAID"
|
||||
| "EXAM_SCHEDULED"
|
||||
@@ -77,10 +79,12 @@ export interface FormFieldConfig {
|
||||
label: Bilingual;
|
||||
type: FormFieldType;
|
||||
required?: boolean;
|
||||
placeholder?: Bilingual;
|
||||
helpText?: Bilingual;
|
||||
options?: { value: string; label: Bilingual }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
maxLength?: number;
|
||||
showWhen?: FieldCondition;
|
||||
readOnly?: boolean;
|
||||
source?: string;
|
||||
@@ -233,6 +237,7 @@ export interface StcwCapacityRow {
|
||||
|
||||
export interface DocumentRequirement {
|
||||
id: string;
|
||||
licenseTypeId: string;
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
@@ -242,7 +247,32 @@ export interface DocumentRequirement {
|
||||
allowedMimeTypes: string[];
|
||||
maxSizeMb: number;
|
||||
requiresValidityDates: boolean;
|
||||
allowMultiple: boolean;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the form-schema builder may put on a form — the field types the engine
|
||||
* understands, which constraints each one honours, the condition operators it
|
||||
* supports, and the prefill sources a read-only field may draw from. Drives
|
||||
* the builder's pickers so they never hardcode a list the server already owns.
|
||||
*/
|
||||
export interface FormSchemaPalette {
|
||||
fieldTypes: {
|
||||
type: FormFieldType;
|
||||
supportsOptions: boolean;
|
||||
supportsRange: boolean;
|
||||
supportsMaxLength: boolean;
|
||||
}[];
|
||||
conditionOperators: ("equals" | "notEquals" | "in" | "isSet")[];
|
||||
prefillSources: string[];
|
||||
}
|
||||
|
||||
/** One problem the server's form-schema lint found. */
|
||||
export interface SchemaIssue {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StaffEvidenceRequirement {
|
||||
|
||||
@@ -23,11 +23,25 @@ import { z } from 'zod';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
// Same email-or-phone rule as LoginPage: a phone-looking value normalizes to
|
||||
// E.164 (bare Ethiopian national numbers default to +251) so the backend
|
||||
// always gets a value it can look the account up by, under the `email` key.
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const schema = z.object({
|
||||
email: z.string().email({ message: 'Enter a valid email' }),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => {
|
||||
if (emailRegex.test(value)) return value;
|
||||
return parsePhoneNumberFromString(value, 'ET')?.number ?? value;
|
||||
})
|
||||
.refine((value) => emailRegex.test(value) || isValidPhoneNumber(value), {
|
||||
message: 'Enter a valid email or phone number',
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
@@ -156,8 +170,8 @@ export function ForgotPasswordPage() {
|
||||
Forgot your password?
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
Enter the email linked to your account and we'll send you a link
|
||||
to reset your password.
|
||||
Enter the email or phone number linked to your account and
|
||||
we'll send you a link to reset your password.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +184,7 @@ export function ForgotPasswordPage() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email address"
|
||||
label="Email or phone"
|
||||
placeholder="you@example.com"
|
||||
size="md"
|
||||
leftSection={<IconMail size={18} />}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useApiMutation } from "@ema-platform/api";
|
||||
import { notify, useErrorHandler } from "@ema-platform/ui";
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
|
||||
import { AuthShell } from "../components/AuthShell";
|
||||
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
|
||||
import type {
|
||||
@@ -53,11 +54,7 @@ export function LoginPage() {
|
||||
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
// Built inside the component (not module scope) so validation messages
|
||||
@@ -67,24 +64,23 @@ export function LoginPage() {
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => {
|
||||
// Convert 09xxxxxxxx -> +2519xxxxxxxx
|
||||
if (/^09\d{8}$/.test(value)) {
|
||||
return `+251${value.substring(1)}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
// A phone-looking value normalizes to E.164 (bare Ethiopian
|
||||
// national numbers, e.g. 09xxxxxxxx, default to +251) so the
|
||||
// international check below can validate it; anything else
|
||||
// (an email) passes through untouched.
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (emailRegex.test(value)) return value;
|
||||
return parsePhoneNumberFromString(value, "ET")?.number ?? value;
|
||||
})
|
||||
.refine(
|
||||
(value) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const phoneRegex = /^\+2519\d{8}$/;
|
||||
|
||||
return emailRegex.test(value) || phoneRegex.test(value);
|
||||
return emailRegex.test(value) || isValidPhoneNumber(value);
|
||||
},
|
||||
{
|
||||
message: t(
|
||||
"login.emailOrPhoneInvalid",
|
||||
"Enter a valid email or phone number (+2519xxxxxxxx)",
|
||||
"Enter a valid email or phone number",
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconAt,
|
||||
IconDeviceMobile,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconUser,
|
||||
@@ -29,7 +28,7 @@ import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { useErrorHandler, passwordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
@@ -88,7 +87,7 @@ export function SignupPage() {
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
|
||||
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
|
||||
phoneNumber,
|
||||
userType: z.literal('individual'),
|
||||
nameEn: z
|
||||
.string()
|
||||
@@ -111,6 +110,8 @@ export function SignupPage() {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -247,12 +248,13 @@ export function SignupPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('signup.phoneLabel', 'Phone number')}
|
||||
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
placeholder={t('signup.phonePlaceholder', '9XX XXX XXX')}
|
||||
value={watch('phoneNumber') || ''}
|
||||
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
|
||||
onBlur={() => trigger('phoneNumber')}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...register('phoneNumber')}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
|
||||
@@ -87,6 +87,11 @@ export interface CurrentProfile {
|
||||
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
|
||||
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
|
||||
seafarerStatusReason?: string | null;
|
||||
/** Identifying particulars for the Seaman Book. Left blank by choice. */
|
||||
bloodType?: string | null;
|
||||
hairColor?: string | null;
|
||||
eyeColor?: string | null;
|
||||
heightCm?: number | null;
|
||||
user: AuthUser;
|
||||
address: CurrentProfileAddress;
|
||||
profession: CurrentProfileProfession;
|
||||
|
||||
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/input/PasswordRequirements";
|
||||
export * from "./lib/input/CountrySelect";
|
||||
export * from "./lib/input/PhoneInput";
|
||||
export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/feedback/use-error-handler";
|
||||
|
||||
@@ -16,9 +16,9 @@ registerLocale(en);
|
||||
registerLocale(am);
|
||||
registerNationalityLocale(nationalityEn);
|
||||
|
||||
type CountryLang = 'en' | 'am';
|
||||
export type CountryLang = 'en' | 'am';
|
||||
|
||||
function resolveLang(lng: string): CountryLang {
|
||||
export function resolveLang(lng: string): CountryLang {
|
||||
return lng === 'am' ? 'am' : 'en';
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function getNationalityName(code: string | null | undefined, lang: Countr
|
||||
return getCountryName(code, lang);
|
||||
}
|
||||
|
||||
function CountryFlag({ code }: { code: string }) {
|
||||
export function CountryFlag({ code }: { code: string }) {
|
||||
const Flag = Flags[code as keyof typeof Flags];
|
||||
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
|
||||
}
|
||||
|
||||
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 { AsYouType, Metadata, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
|
||||
|
||||
/** Accepts `09xxxxxxxx` or `+2519xxxxxxxx`; always yields `+2519xxxxxxxx`. */
|
||||
export const ethiopianPhone = z
|
||||
/**
|
||||
* Accepts any international number in E.164 (`+<country><number>`) or a
|
||||
* bare national number, which is assumed Ethiopian (`0911223344` ->
|
||||
* `+251911223344`) for backward compatibility with existing records.
|
||||
*/
|
||||
export const phoneNumber = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
|
||||
.refine((v) => /^\+2519\d{8}$/.test(v), {
|
||||
message: 'Enter a valid phone number (+2519xxxxxxxx)',
|
||||
.transform((v) => parsePhoneNumberFromString(v, 'ET')?.number ?? v)
|
||||
.superRefine((v, ctx) => {
|
||||
if (!v) ctx.addIssue({ code: 'custom', message: 'Phone number is required' });
|
||||
else if (!isValidPhoneNumber(v)) ctx.addIssue({ code: 'custom', message: 'Enter a valid phone number' });
|
||||
});
|
||||
|
||||
/** Same rules, but blank is allowed. */
|
||||
export const optionalEthiopianPhone = z.union([z.literal(''), ethiopianPhone]).optional();
|
||||
export const optionalPhoneNumber = z.union([z.literal(''), phoneNumber]).optional();
|
||||
|
||||
/**
|
||||
* Digits the field should hold after an edit. A keystroke that only removed
|
||||
* a formatting character (backspacing the ')' out of "(415)") leaves the
|
||||
* digits unchanged, which would otherwise make the caret stick — drop a real
|
||||
* digit in that case.
|
||||
*/
|
||||
export function nextNationalDigits(text: string, prevDisplay: string): string {
|
||||
const digits = text.replace(/\D/g, '');
|
||||
const deleting = text.length < prevDisplay.length;
|
||||
if (deleting && digits === prevDisplay.replace(/\D/g, '')) return digits.slice(0, -1);
|
||||
return digits;
|
||||
}
|
||||
|
||||
/** National digits of a stored value, for display in the number box. */
|
||||
export function toNationalDigits(value: string, country: CountryCode): string {
|
||||
if (!value) return '';
|
||||
// `country` also resolves legacy national records ("0911223344").
|
||||
const parsed = parsePhoneNumberFromString(value, country);
|
||||
if (parsed) return parsed.nationalNumber;
|
||||
if (value.startsWith('+')) {
|
||||
const prefix = `+${getCountryCallingCode(country)}`;
|
||||
if (value.startsWith(prefix)) return value.slice(prefix.length).replace(/\D/g, '');
|
||||
}
|
||||
return value.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* E.164 for the digits typed so far. Incomplete numbers don't parse, so they
|
||||
* fall back to a plain dial-code concatenation rather than collapsing to ''
|
||||
* — the value has to survive mid-typing for the field to be usable.
|
||||
*/
|
||||
export function toE164(digits: string, country: CountryCode): string {
|
||||
if (!digits) return '';
|
||||
return (
|
||||
parsePhoneNumberFromString(digits, country)?.number ??
|
||||
`+${getCountryCallingCode(country)}${digits}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Digits grouped for display ("91 122 3344"). Formatted as an international
|
||||
* number with the dial code cut off: AsYouType's national mode leaves the
|
||||
* number ungrouped unless the trunk prefix was typed.
|
||||
*/
|
||||
export function formatNational(digits: string, country: CountryCode): string {
|
||||
if (!digits) return '';
|
||||
const prefix = `+${getCountryCallingCode(country)}`;
|
||||
const formatted = new AsYouType().input(prefix + digits);
|
||||
return formatted.startsWith(prefix) ? formatted.slice(prefix.length).trimStart() : digits;
|
||||
}
|
||||
|
||||
const metadata = new Metadata();
|
||||
|
||||
/** Longest national number the country's numbering plan allows. */
|
||||
export function maxNationalLength(country: CountryCode): number {
|
||||
metadata.selectNumberingPlan(country);
|
||||
return Math.max(...(metadata.numberingPlan?.possibleLengths() ?? [15]));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the typed digits exceed the country's longest number. Judged on
|
||||
* the national part once it parses, so a trunk prefix (0911223344) or typed
|
||||
* country code (251911223344) isn't counted against the limit.
|
||||
*/
|
||||
export function exceedsMaxLength(digits: string, country: CountryCode): boolean {
|
||||
const national = parsePhoneNumberFromString(digits, country)?.nationalNumber ?? digits;
|
||||
return national.length > maxNationalLength(country);
|
||||
}
|
||||
|
||||
11
libs/ui/vite.config.mts
Normal file
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",
|
||||
"i18next": "^25.6.0",
|
||||
"js-cookie": "^3.0.8",
|
||||
"libphonenumber-js": "^1.13.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -77,6 +77,9 @@ importers:
|
||||
js-cookie:
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.8
|
||||
libphonenumber-js:
|
||||
specifier: ^1.13.11
|
||||
version: 1.13.11
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.8
|
||||
@@ -4837,6 +4840,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
libphonenumber-js@1.13.11:
|
||||
resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -12006,6 +12012,8 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libphonenumber-js@1.13.11: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
allowBuilds:
|
||||
canvas: set this to true or false
|
||||
core-js: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
nx: set this to true or false
|
||||
|
||||
Reference in New Issue
Block a user