diff --git a/apps/backoffice/index.html b/apps/backoffice/index.html index 8d1d8072c..851a44f30 100644 --- a/apps/backoffice/index.html +++ b/apps/backoffice/index.html @@ -6,9 +6,122 @@ EMA Backoffice + +
+
+
+
+ + + + + + + + + + + + + + + EMA +
+
ETHIOPIAN MARITIME AUTHORITY
+
የኢትዮጵያ ማሪታይም ባለስልጣን
+
Loading Maritime Backoffice…
+
+
diff --git a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts index 761a89e15..c2714f856 100644 --- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts +++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { notifications } from '@mantine/notifications'; import { useTranslation } from 'react-i18next'; import { extractErrorMessage } from '@ema-platform/api'; @@ -13,12 +13,14 @@ interface PreviewArgs { /** * Renders the editor's current contents, not the saved row, so unsaved edits - * are what you see. Opened as a blob so it never leaves a file behind. + * are what you see. Opened as a blob into `PdfPreviewModal` rather than a new + * tab, so the designer never loses their place. */ export function useTemplatePreview() { const { t } = useTranslation(); + const [previewUrl, setPreviewUrl] = useState(null); - return useCallback( + const open = useCallback( async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => { try { // The preview returns a PDF stream, not JSON, so it bypasses RTK Query @@ -38,10 +40,7 @@ export function useTemplatePreview() { }), }); if (!response.ok) throw new Error(await response.text()); - const url = URL.createObjectURL(await response.blob()); - window.open(url, '_blank', 'noopener'); - // Give the new tab time to read it before revoking. - setTimeout(() => URL.revokeObjectURL(url), 60_000); + setPreviewUrl(URL.createObjectURL(await response.blob())); } catch (err) { notifications.show({ color: 'red', @@ -52,4 +51,13 @@ export function useTemplatePreview() { }, [t], ); + + const close = useCallback(() => { + setPreviewUrl((current) => { + if (current) URL.revokeObjectURL(current); + return null; + }); + }, []); + + return { previewUrl, open, close }; } diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index 7f49af593..d30aa5d2f 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -31,7 +31,7 @@ import { useUpdateLicenseValidityMutation, useUpdateLicenseTemplateMutation, } from '@ema-platform/api'; -import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui'; +import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui'; import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth'; import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel'; import { DesignerToolbar } from '../components/DesignerToolbar'; @@ -86,7 +86,7 @@ export function CertificateDesignerPage() { const draft = useTemplateDraft(templates); const run = useDesignerActions(); - const openPreview = useTemplatePreview(); + const { previewUrl, open: openPreview, close: closePreview } = useTemplatePreview(); const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(''); @@ -377,6 +377,13 @@ export function CertificateDesignerPage() { }, t('designer.created', 'Draft created')) } /> + + ); } 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 ? ( + + ) : ( + + + + + + + + + {sample && ( + + + {t('numberFormat.next', 'Next number will look like')} + {sample} + + + )} + + + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx index e6bfd6059..3e38cc111 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx @@ -21,6 +21,7 @@ import { IconBriefcase, IconMap, IconCertificate, + IconHash, IconInfoCircle, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -30,9 +31,11 @@ import { AdvancedTable, useServerTable, ModalFooter, + PageLoader, } from "@ema-platform/ui"; import { LocationPage } from "../../../location/pages/LocationPage"; import { CertificationPage } from "../../../certification/pages/CertificationPage"; +import { NumberFormatTab } from "../../components/NumberFormatTab"; import { useGetOrganizationsQuery, useGetProfessionsQuery, @@ -292,11 +295,7 @@ function ProfessionTab() { ]; if (isLoading) { - return ( -
- -
- ); + return ; } if (isError) { @@ -398,6 +397,9 @@ export function ConfigurationPage() { > {t("certification.title")} + }> + {t("numberFormat.title", "Number Formats")} + @@ -411,6 +413,10 @@ export function ConfigurationPage() { + + + +
); diff --git a/apps/backoffice/src/app/features/configuration/types/configuration.ts b/apps/backoffice/src/app/features/configuration/types/configuration.ts index ddde32ae3..7ee61fabe 100644 --- a/apps/backoffice/src/app/features/configuration/types/configuration.ts +++ b/apps/backoffice/src/app/features/configuration/types/configuration.ts @@ -46,3 +46,41 @@ export interface UpdateProfessionPayload { description?: NamePair; isActive?: boolean; } + +/** Identifiers whose rendered shape is authored in the backoffice. */ +export type NumberFormatScope = + | 'SEAFARER_NUMBER' + | 'SEAMAN_BOOK_NUMBER' + | 'BTC_NUMBER'; + +/** + * The shape of a generated identifier — prefix, optional year, separator and + * zero-padded counter, e.g. SEA-2026-000001. + * + * Only the shape. The counter itself is server-side and atomic, so nothing + * here can cause two people to be issued the same number. + */ +export interface NumberFormatConfig { + id: string; + scope: NumberFormatScope; + prefix: string; + includeYear: boolean; + separator: string; + sequenceLength: number; + startingNumber: number; + activeFrom: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface NumberFormatPayload { + scope: NumberFormatScope; + prefix: string; + includeYear?: boolean; + separator?: string; + sequenceLength?: number; + startingNumber?: number; + activeFrom?: string; + isActive?: boolean; +} diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx index 443b66eae..927ca47a3 100644 --- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx @@ -11,7 +11,7 @@ import { } from '@mantine/core'; import { IconChevronRight } from '@tabler/icons-react'; import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api'; -import { AdvancedTable, useServerTable } from '@ema-platform/ui'; +import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui'; import { dashboardQueueColumns } from './columns'; /** @@ -28,11 +28,7 @@ export function DashboardPage() { const table = useServerTable(); if (queue.isLoading || mine.isLoading) { - return ( -
- -
- ); + return ; } const unclaimed = queue.data?.items ?? []; diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index e49efe907..6c09dd93b 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -54,6 +54,7 @@ import { QuestionAssigner } from '../components/QuestionAssigner'; import { RecordResultModal } from '../../result/components/RecordResultModal'; import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel'; import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel'; +import { PageLoader } from '@ema-platform/ui'; import type { ExamStatus, QuestionBrief } from '../types/exam'; const STATUS_COLOR: Record = { @@ -129,11 +130,7 @@ export function ExamDetailPage() { }, [allQuestions, exam?.certificationId, exam?.form]); if (isLoading) - return ( -
- -
- ); + return ; if (isError || !exam) { return ( diff --git a/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx b/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx new file mode 100644 index 000000000..018dac7fe --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx @@ -0,0 +1,108 @@ +import { Avatar, Badge, Group, Paper, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { ApplicationApplicant } from '@ema-platform/api'; +import { useDateDisplayer } from '@ema-platform/shared'; + +interface ApplicantCardProps { + applicant: ApplicationApplicant; +} + +/** + * Who the reviewer is deciding about. + * + * A company licence names itself in the page title (`companyName`); a seafarer + * registration has no company, so the officer's screen led with an application + * number and the human behind it was somewhere in the form answers. This puts + * the identity where it belongs on a person-centric review: name, national ID, + * contact, and — for a seafarer who already holds one — their number and + * standing, which is what says whether this is a first registration or a + * duplicate. + * + * Read-only and sourced from the profile, not the form: this is the record the + * registration will be written onto, so a reviewer comparing the two is exactly + * the intended use. + */ +export function ApplicantCard({ applicant }: ApplicantCardProps) { + const { t } = useTranslation(); + const showDate = useDateDisplayer(); + + const fullName = [applicant.firstName, applicant.middleName, applicant.lastName] + .filter(Boolean) + .join(' '); + const initials = [applicant.firstName, applicant.lastName] + .filter(Boolean) + .map((part) => part?.[0]?.toUpperCase() ?? '') + .join(''); + + return ( + + + + {initials || '—'} + +
+ + {fullName || t('review.nameMissing', 'Name not on profile')} + + {applicant.seafarerNumber ? ( + + + {applicant.seafarerNumber} + + {applicant.seafarerStatus && ( + + {applicant.seafarerStatus} + + )} + + ) : ( + + {t('review.notYetRegistered', 'Not yet registered')} + + )} +
+
+ + + + + + + + + +
+ ); +} + +/** One label/value line, omitted entirely when there is nothing to show. */ +function Row({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( + + + {label} + + + {value} + + + ); +} diff --git a/apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx b/apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx index 0403cfaad..e224fc028 100644 --- a/apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx +++ b/apps/backoffice/src/app/features/license-review/components/DecisionBar.tsx @@ -76,9 +76,13 @@ export function DecisionBar({ role="region" aria-label={t('review.decisionBar', 'Decision bar')} > - + {/* Wraps rather than overflows: at narrow widths the nowrap row pushed + the workflow buttons past the viewport edge, so Assign, Escalate and + Hold were simply not there. Wrapping drops them onto a second line + instead of off the screen. */} + {/* Left: where the application stands, and who has it. */} - + {t(`queue.statusValues.${status}`, STATUS_LABELS[status])} @@ -124,7 +128,7 @@ export function DecisionBar({ {/* Right: the decision. */} - + {primary.map((action) => ( onAction(action)} > {t(action.labelKey)} @@ -207,7 +218,13 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) { if (action.enabled) return button; return ( - + {button} ); @@ -234,7 +251,13 @@ function MenuAction({ ); if (action.enabled) return item; return ( - +
{item}
); diff --git a/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx b/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx index f2f757abd..7d55d4bce 100644 --- a/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx +++ b/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState } from "react"; import { ActionIcon, Alert, @@ -13,7 +13,7 @@ import { Text, TextInput, Tooltip, -} from '@mantine/core'; +} from "@mantine/core"; import { IconAlertCircle, IconCheck, @@ -22,23 +22,27 @@ import { IconFileText, IconRotate, IconX, -} from '@tabler/icons-react'; -import { useTranslation } from 'react-i18next'; +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { + conditionHolds, useClearDocumentReviewMutation, useGetDocumentReviewsQuery, useLocalized, useReviewDocumentMutation, type Attachment, type DocumentRequirement, -} from '@ema-platform/api'; -import { notifications } from '@mantine/notifications'; +} from "@ema-platform/api"; +import { notifications } from "@mantine/notifications"; +import { PdfPreviewModal } from "@ema-platform/ui"; interface DocumentsTabProps { applicationId: string; attachments: Attachment[]; /** From the licence type config, so completeness is measured against rules. */ requirements: DocumentRequirement[]; + /** Applicant answers used to evaluate conditional document requirements. */ + formData: Record>; /** documentKey -> remark. Owned by the review page. */ flags: Record; onToggleFlag: (documentKey: string) => void; @@ -58,6 +62,7 @@ export function DocumentsTab({ applicationId, attachments, requirements, + formData, flags, onToggleFlag, onFlagRemark, @@ -80,16 +85,16 @@ export function DocumentsTab({ async function decide( documentKey: string, - decision: 'ACCEPTED' | 'REJECTED', + decision: "ACCEPTED" | "REJECTED", attachmentId?: string, ) { const reason = rejecting[documentKey]?.trim(); - if (decision === 'REJECTED' && !reason) { + if (decision === "REJECTED" && !reason) { // The applicant is shown this verbatim, so refuse to send an empty one. notifications.show({ - color: 'red', - title: t('review.documents.reasonRequired', 'A reason is required'), - message: '', + color: "red", + title: t("review.documents.reasonRequired", "A reason is required"), + message: "", }); return; } @@ -98,7 +103,7 @@ export function DocumentsTab({ id: applicationId, documentKey, decision, - reason: decision === 'REJECTED' ? reason : undefined, + reason: decision === "REJECTED" ? reason : undefined, attachmentId, }).unwrap(); setRejecting((prev) => { @@ -108,24 +113,29 @@ export function DocumentsTab({ }); } catch { notifications.show({ - color: 'red', - title: t('review.documents.saveFailed', 'Could not save the verdict'), - message: '', + color: "red", + title: t("review.documents.saveFailed", "Could not save the verdict"), + message: "", }); } } const uploadedKeys = new Set(attachments.map((a) => a.documentKey)); const requirementByKey = new Map(requirements.map((r) => [r.key, r])); - const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL'); + const mandatory = requirements.filter( + (r) => + r.mode === "ALWAYS" || + (r.mode === "CONDITIONAL" && + conditionHolds(r.conditionExpression, formData)), + ); const missing = mandatory.filter((r) => !uploadedKeys.has(r.key)); const completeness = mandatory.length ? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100) : 100; const previewFile = preview?.files?.[0]; - const isImage = previewFile?.mimeType?.startsWith('image/'); - const isPdf = previewFile?.mimeType === 'application/pdf'; + const isImage = previewFile?.mimeType?.startsWith("image/"); + const isPdf = previewFile?.mimeType === "application/pdf"; return ( @@ -133,25 +143,30 @@ export function DocumentsTab({ - {t('review.documents.completeness', 'Required documents')} + {t("review.documents.completeness", "Required documents")} - + {mandatory.length - missing.length}/{mandatory.length} {missing.length > 0 && ( - } variant="light"> + } + variant="light" + > - {t('review.documents.missing', 'Not yet uploaded')}:{' '} - {missing.map((r) => localized(r.name) || r.key).join(', ')} + {t("review.documents.missing", "Not yet uploaded")}:{" "} + {missing.map((r) => localized(r.name) || r.key).join(", ")} )} @@ -174,45 +189,55 @@ export function DocumentsTab({ opaque badge painted over the bleeding text. Nested here with its own wrap, the name truncates cleanly instead. */} - - {localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey} + + {localized( + requirementByKey.get(attachment.documentKey)?.name, + ) || attachment.documentKey} {verdict && ( ) : ( ) } > - {verdict.decision === 'ACCEPTED' - ? t('review.documents.accepted', 'Accepted') - : t('review.documents.rejected', 'Rejected')} + {verdict.decision === "ACCEPTED" + ? t("review.documents.accepted", "Accepted") + : t("review.documents.rejected", "Rejected")} )} {flagged && ( - {t('review.documents.flagged', 'Correction requested')} + {t("review.documents.flagged", "Correction requested")} )} - {file?.originalName ?? t('review.documents.noFile', 'No file')} + {file?.originalName ?? + t("review.documents.noFile", "No file")}
@@ -221,8 +246,11 @@ export function DocumentsTab({ @@ -233,15 +261,18 @@ export function DocumentsTab({ disabled={!file?.url} onClick={() => setPreview(attachment)} > - {t('review.documents.view', 'View')} + {t("review.documents.view", "View")} @@ -253,7 +284,7 @@ export function DocumentsTab({ download={file?.originalName} target="_blank" rel="noreferrer" - aria-label={t('review.documents.download', 'Download')} + aria-label={t("review.documents.download", "Download")} > @@ -266,19 +297,28 @@ export function DocumentsTab({ - decide(attachment.documentKey, 'ACCEPTED', attachment.id) + decide( + attachment.documentKey, + "ACCEPTED", + attachment.id, + ) } > @@ -288,20 +328,25 @@ export function DocumentsTab({ setRejecting((prev) => ({ ...prev, - [attachment.documentKey]: verdict?.reason ?? '', + [attachment.documentKey]: verdict?.reason ?? "", })) } > @@ -310,11 +355,11 @@ export function DocumentsTab({ {verdict && ( - + clearReview({ id: applicationId, @@ -330,7 +375,7 @@ export function DocumentsTab({ size="xs" checked={flagged} onChange={() => onToggleFlag(attachment.documentKey)} - label={t('review.documents.includeInAdjustment', 'Send back')} + label={t("review.documents.includeInAdjustment", "Send back")} />
@@ -342,8 +387,8 @@ export function DocumentsTab({ size="xs" autoFocus placeholder={t( - 'review.documents.rejectReason', - 'Why must this document be corrected?', + "review.documents.rejectReason", + "Why must this document be corrected?", )} value={rejecting[attachment.documentKey]} onChange={(e) => { @@ -363,10 +408,10 @@ export function DocumentsTab({ loading={saving} disabled={!rejecting[attachment.documentKey]?.trim()} onClick={() => - decide(attachment.documentKey, 'REJECTED', attachment.id) + decide(attachment.documentKey, "REJECTED", attachment.id) } > - {t('review.documents.confirmReject', 'Reject')} + {t("review.documents.confirmReject", "Reject")}
)} @@ -376,15 +421,20 @@ export function DocumentsTab({ mt="sm" size="xs" placeholder={t( - 'review.documents.adjustmentNote', - 'What must the applicant correct?', + "review.documents.adjustmentNote", + "What must the applicant correct?", )} value={flags[attachment.documentKey]} - onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)} + onChange={(e) => + onFlagRemark(attachment.documentKey, e.currentTarget.value) + } error={ flags[attachment.documentKey].trim() ? undefined - : t('review.documents.reasonRequired', 'A reason is required') + : t( + "review.documents.reasonRequired", + "A reason is required", + ) } /> )} @@ -392,15 +442,28 @@ export function DocumentsTab({ ); })} + setPreview(null)} + url={previewFile?.url ?? ""} + title={ + preview + ? localized(requirementByKey.get(preview.documentKey)?.name) || + preview.documentKey + : "" + } + /> + setPreview(null)} position="right" size="xl" title={ preview - ? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey - : '' + ? localized(requirementByKey.get(preview.documentKey)?.name) || + preview.documentKey + : "" } // Focus is trapped and returned so keyboard users are not dropped at // the top of the page when the drawer closes. @@ -408,25 +471,22 @@ export function DocumentsTab({ returnFocus > {previewFile?.url ? ( - isPdf ? ( -