diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx new file mode 100644 index 000000000..8b3ead1fc --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx @@ -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 ( + + {allowClear && ( + onChange(e.currentTarget.checked ? { field: '', equals: '' } : null)} + /> + )} + + {active && ( + + c.path)} + value={value?.field ?? ''} + onChange={setField} + /> + + + ({ + 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' ? ( + setValueRaw(String(e.currentTarget.checked))} + /> + ) : ( + setValueRaw(e.currentTarget.value)} + /> + ) + )} + + {operator === 'in' && target?.field.type === 'SELECT' && ( + 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} + /> + + ({ value: f.type, label: f.type }))} + value={draft.type} + onChange={(v) => v && setDraft((d) => ({ ...d, type: v as FormFieldConfig['type'] }))} + allowDeselect={false} + /> + + setDraft((d) => ({ ...d, required: e.currentTarget.checked }))} + /> + + setDraft((d) => ({ ...d, placeholder: v }))} + /> + + setDraft((d) => ({ ...d, helpText: v }))} + /> + + {typeInfo?.supportsRange && ( + + setDraft((d) => ({ ...d, min: typeof v === 'number' ? v : undefined }))} + /> + setDraft((d) => ({ ...d, max: typeof v === 'number' ? v : undefined }))} + /> + + )} + + {typeInfo?.supportsMaxLength && ( + setDraft((d) => ({ ...d, maxLength: typeof v === 'number' ? v : undefined }))} + /> + )} + + {typeInfo?.supportsOptions && ( + + + {t('certReq.field.options', 'Options')} + + + {(draft.options ?? []).map((o, i) => ( + + updateOption(i, { value: e.currentTarget.value })} + style={{ flex: 1 }} + /> + updateOption(i, { label: v })} + style={{ flex: 2 }} + /> + + + ))} + + )} + + + setDraft((d) => ({ ...d, showWhen: v ?? undefined }))} + targets={conditionTargets} + palette={palette} + /> + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx new file mode 100644 index 000000000..ddeb712f1 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx @@ -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(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(licenseType.formSchema.sections); + const [issues, setIssues] = useState(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(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) { + 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 ( + + + + {t( + 'certReq.schema.subtitle', + 'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.', + )} + + + + + + + + + {issues !== null && issues.length > 0 && ( + } title={t('certReq.schema.issuesFound', 'Issues found')}> + + {issues.map((issue, i) => ( + + {issue.path}: {issue.message} + + ))} + + + )} + + {sections.length === 0 ? ( + setSectionDrawer({ section: null }) }} + /> + ) : ( + + {sections.map((section, sIndex) => ( + + + + +
+ + {localized(section.title) || section.key} + {section.group && {t('certReq.section.groupBadge', 'group')}: {section.group}} + {section.showWhen && {t('certReq.condition.badge', 'conditional')}} + + key: {section.key} +
+
+ + mutate(moveItem(sections, sIndex, -1))}> + + + mutate(moveItem(sections, sIndex, 1))}> + + + setSectionDrawer({ section })}> + + + setDeleteSection(section)}> + + + +
+ + + {section.fields.map((field, fIndex) => ( + + + +
+ + {localized(field.label) || field.key} + {field.required && *} + + + {field.type} + key: {field.key} + {field.showWhen && {t('certReq.condition.badge', 'conditional')}} + +
+
+ + mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, -1) } : s)))}> + + + mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, 1) } : s)))}> + + + setFieldDrawer({ sectionKey: section.key, field })}> + + + setDeleteField({ sectionKey: section.key, field })}> + + + +
+
+ ))} + + +
+
+ ))} +
+ )} + + setSectionDrawer(null)} + section={sectionDrawer?.section ?? null} + onSave={saveSection} + palette={palette} + conditionTargets={conditionTargets} + /> + + setFieldDrawer(null)} + field={fieldDrawer?.field ?? null} + onSave={saveField} + palette={palette} + conditionTargets={conditionTargets} + /> + + setDeleteSection(null)} title={t('certReq.section.delete', 'Delete section')} size="sm"> + + + {t('certReq.section.deleteConfirm', 'Remove "{{name}}" and all of its fields from this schema?', { + name: deleteSection ? localized(deleteSection.title) || deleteSection.key : '', + })} + + + + + + + + + setDeleteField(null)} title={t('certReq.field.delete', 'Delete field')} size="sm"> + + + {t('certReq.field.deleteConfirm', 'Remove "{{name}}" from this section?', { + name: deleteField ? localized(deleteField.field.label) || deleteField.field.key : '', + })} + + + + + + + +
+ ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx new file mode 100644 index 000000000..6110b8358 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx @@ -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) => void; + palette: FormSchemaPalette | undefined; + conditionTargets: ConditionTarget[]; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(emptySection()); + const [keyError, setKeyError] = useState(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 ( + {isNew ? t('certReq.section.add', 'Add section') : t('certReq.section.edit', 'Edit section')}} + > + + setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + /> + + setDraft((d) => ({ ...d, title: v }))} + /> + + setDraft((d) => ({ ...d, description: v }))} + /> + + setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))} + /> + + setDraft((d) => ({ ...d, groupOrder: typeof v === 'number' ? v : undefined }))} + /> + + setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : undefined }))} + /> + + + setDraft((d) => ({ ...d, showWhen: v ?? undefined }))} + targets={conditionTargets} + palette={palette} + /> + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/config/schema-paths.ts b/apps/backoffice/src/app/features/certificate-requirements/config/schema-paths.ts new file mode 100644 index 000000000..7375b75ca --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/config/schema-paths.ts @@ -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; +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts b/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts new file mode 100644 index 000000000..e1c2337cd --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts @@ -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, 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], + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx new file mode 100644 index 000000000..8e9414a0a --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx @@ -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(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 ( + + + + + {isError ? ( + refetch()} + icon={IconAlertCircle} + /> + ) : isLoading ? ( + + ) : ( + + - )} - - {/* Held certificates */} - {held.size > 0 && ( - - Already Issued to You - - {[...held].map((hid) => { - const c = CERT_CATALOG.find((x) => x.id === hid); - if (!c) return null; - return {c.label}; - })} - - - )} - - {/* Competency detail for selected */} - {selectedCert && ( - <> - - - - )} - - {/* All department certificates reference */} - - The full career pathway for the {deptLabel[MOCK_PROFILE.department]} department. You must progress in order — each certificate requires the previous one. - - {deptCerts.map((c) => ( - - ))} - - - - - - - - - )} - - {/* ── STEP 1 — Upload Documents ── */} - {step === 1 && selectedCert && ( - - - Upload Documents - - } p="sm"> - - Your Seaman Book and passport-size photo are already on file — no need to re-upload them. - Upload one certificate file per competency area plus your sea service record and Training Record Book. - PDF preferred. Max 5 MB each. - - - - {MOCK_PROFILE.medicalExpired && ( - } p="sm"> - Your medical fitness certificate on file has expired. - Upload a current valid medical fitness certificate to proceed. - - )} - - {/* Sea service */} - {(() => { - const svc = docSlots.find((d) => d.key === 'sea-service')!; - return ( - - - {svc.label} - * - - {svc.description} - - } value={docs[svc.key] ?? null} onChange={(f) => setDoc(svc.key, f)} style={{ flex: 1 }} size="sm" clearable /> - {docs[svc.key] && } - - - ); - })()} - - {/* Competency certificates — one per area */} - - Competency Certificates * - - Upload one certificate or documentary evidence file for each competency area listed below. - These must directly correspond to the STCW competency items for {selectedCert.label}. - - - {docSlots.filter((d) => d.isCompetency).map((doc) => ( - - - {doc.label} - * - - {doc.description} - - } value={docs[doc.key] ?? null} onChange={(f) => setDoc(doc.key, f)} style={{ flex: 1 }} size="sm" clearable /> - {docs[doc.key] && } - - - ))} - - - - {/* TRB */} - {(() => { - const trb = docSlots.find((d) => d.key === 'trb')!; - return ( - - - {trb.label} - {trb.required ? * : optional for first cert} - - {trb.description} - {trb.required && ( - } p="xs" mb="xs"> - - An EMA officer will physically inspect your original TRB at the office before scheduling your examination. - Upload a scanned copy here — you will be asked to bring the original when called. - - - )} - - } value={docs[trb.key] ?? null} onChange={(f) => setDoc(trb.key, f)} style={{ flex: 1 }} size="sm" clearable /> - {docs[trb.key] && } - - - ); - })()} - - {/* Medical if expired */} - {MOCK_PROFILE.medicalExpired && (() => { - const med = docSlots.find((d) => d.key === 'medical'); - if (!med) return null; - return ( - - {med.label} * - {med.description} - - } value={docs[med.key] ?? null} onChange={(f) => setDoc(med.key, f)} style={{ flex: 1 }} size="sm" clearable /> - {docs[med.key] && } - - - ); - })()} - - {/* Upload progress summary */} - - Upload Progress - - {docSlots.map((d) => ( - - {docs[d.key] - ? - : } - - {d.label} - {!docs[d.key] && d.required && *} - - - ))} - - - - } p="sm"> - - After submission, EMA officers will review your documents and TRB. If approved, they will contact you with an examination date and venue. - Your original TRB must be brought to the office for physical inspection before the exam is scheduled. - - - - - - - - - - )} - - {/* ── STEP 2 — Payment ── */} - {step === 2 && selectedCert && ( - - - Fee Payment - - - Fee Breakdown — {selectedCert.type}: {selectedCert.label} - - {FEES.map((f) => ( - - {f.label} - ETB {f.amount.toFixed(2)} - - ))} - - - - Total - ETB {TOTAL.toFixed(2)} - - - - Select Payment Method - - {[ - { key: 'cbe', label: 'CBE Bank Transfer', acct: '1000123456789', name: 'EMA Maritime Authority', hint: 'Use your full name and certificate type as the transfer description.' }, - { key: 'telebirr', label: 'Telebirr', acct: '+251 11 551 0000', name: 'EMA Maritime Authority', hint: 'Screenshot your confirmation and upload below.' }, - ].map((m) => ( - setPaymentMethod(m.key)} - > - - {m.label} - {paymentMethod === m.key && } - - Account: {m.acct} - Name: {m.name} - {m.hint} - - ))} - - - {paymentMethod && ( - - - - setPaymentRef(e.currentTarget.value)} - size="sm" - required - /> - setPaymentDate(e.currentTarget.value)} - size="sm" - required - /> - - } - value={paymentFile} - onChange={setPaymentFile} - size="sm" - required - clearable - /> - - )} - - - - - - - - )} - - {/* ── STEP 3 — Review & Submit ── */} - {step === 3 && selectedCert && ( - - - Review & Submit - - - - Certificate Applying For - {selectedCert.type} - {selectedCert.label} - {selectedCert.stcwRef} - - - - Level - {selectedCert.level} Level - - - - Documents from System - - Seaman Book — {MOCK_PROFILE.seamanBookNo} - Passport Photo — on file - - - - - Uploaded Documents - - {docSlots.map((d) => ( - - {docs[d.key] - ? - : } - {d.label} - {!docs[d.key] && !d.required && not uploaded} - - ))} - - - - - Payment - {paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'} - Ref: {paymentRef} - ETB {TOTAL.toFixed(2)} - - - - - - }> - Processing time: 10–15 working days after examination - - EMA will notify you at each stage: document verification → payment confirmation → examination invitation → result → certificate issuance. - - - - - - - - - - )} -
- ); -} diff --git a/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx b/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx index 4ef90cb06..b0fb505ed 100644 --- a/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx +++ b/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx @@ -144,6 +144,11 @@ export function DocumentSlots({ )} + {requirement.description && ( + + {localized(requirement.description)} + + )} {existing?.files?.[0] && ( {existing.files[0].originalName} ·{' '} diff --git a/apps/portal/src/app/features/licensing/components/ExamStageActions.tsx b/apps/portal/src/app/features/licensing/components/ExamStageActions.tsx index baae745b0..76ceebaa4 100644 --- a/apps/portal/src/app/features/licensing/components/ExamStageActions.tsx +++ b/apps/portal/src/app/features/licensing/components/ExamStageActions.tsx @@ -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 ( + ); + } + + // Paid — queued for backoffice review. Nothing for the candidate to do. + if (app.status === 'ELIGIBILITY_PAID') { + return ( + + ); + } + + // Failed a sitting: the only decision left is whether to pay for another. + if (app.status === 'EXAM_FAILED') { + return ( + ); } diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 2cf452a1b..73728c62e 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -737,9 +737,14 @@ export function LicenseApplicationPage() { return (
{index > 0 && } - + {localized(section.title)} + {section.description && ( + + {localized(section.description)} + + )} {locked && ( void; onCertificate: (app: LicenseApplication) => void; onPay: (app: LicenseApplication) => void; - onRequestExamFee: (app: LicenseApplication) => void; + onRetakeExam: (app: LicenseApplication) => void; onOpen: (app: LicenseApplication) => void; }, ): AdvancedColumn { @@ -46,14 +47,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') && (