From e82714ba1f1194960aaef58a9fbbf6069f140510 Mon Sep 17 00:00:00 2001 From: Nati Date: Sat, 22 Aug 2026 06:11:02 +0000 Subject: [PATCH 1/9] Enhance exam selection by displaying certification rank in options and remove admission number input from scheduling modal --- .../features/exam/pages/ExamPage/index.tsx | 12 ++++++++++- .../components/ScheduleExamModal.tsx | 21 ++++++------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index e0adfa5ab..b515509d8 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -24,6 +24,7 @@ import { import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui"; import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; import { useGetCertificationsQuery } from "../../../certification/api/certification-api"; +import { RANK_KEY_OPTIONS } from "../../../certification/types/certification"; import { useGetExamsQuery, useCreateExamMutation, @@ -383,9 +384,18 @@ export function ExamPage() { const [statusOpened, { open: openStatus, close: closeStatus }] = useDisclosure(false); + // Rank in the label: an exam inherits its STCW rank from the certification + // it is created under (Certification.rankKey), so the officer sees which + // rank a sitting will serve at the moment they pick the subject. const certOptions = certifications .filter((c) => c.isActive) - .map((c) => ({ value: c.id, label: c.name[locale] })); + .map((c) => { + const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label; + return { + value: c.id, + label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale], + }; + }); const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? "-"; diff --git a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx b/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx index b68de8778..641ffc864 100644 --- a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx +++ b/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core'; import { IconCalendarEvent } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { ModalFooter } from '@ema-platform/ui'; @@ -11,11 +11,7 @@ interface Props { applicantName: string; loading: boolean; onClose: () => void; - onConfirm: (payload: { - examId: string; - admissionNumber?: string; - examDate?: string; - }) => void; + onConfirm: (payload: { examId: string; examDate?: string }) => void; } /** @@ -38,7 +34,6 @@ export function ScheduleExamModal({ const { t } = useTranslation(); const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened }); const [examId, setExamId] = useState(null); - const [admissionNumber, setAdmissionNumber] = useState(''); const options = (exams ?? []).map((exam) => ({ value: exam.id, @@ -52,7 +47,6 @@ export function ScheduleExamModal({ if (!examId) return; onConfirm({ examId, - admissionNumber: admissionNumber.trim() || undefined, examDate: selected?.date ? String(selected.date) : undefined, }); } @@ -92,15 +86,12 @@ export function ScheduleExamModal({ /> )} - + {t( 'review.scheduleExam.admissionHint', - 'Leave blank to let the system issue one.', + 'An admission number is issued automatically when the candidate is seated.', )} - value={admissionNumber} - onChange={(e) => setAdmissionNumber(e.currentTarget.value)} - /> + + + + ), + }, + ]; + + return ( + + + {t("configuration.departments", "Departments")} + {!showForm && ( + + )} + + + a.sortOrder - b.sortOrder)} + tableName={t("configuration.departments", "Departments")} + itemCount={departments.length} + pageIndex={0} + onPageChange={() => {}} + pageSize={departments.length || 10} + onPageSizeChange={() => {}} + refresh={refetch} + isLoading={isFetching} + /> + + +
+ + + + + + + + + + +
+
+ + + + {t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })} + + + + + + +
+ ); +} + +// ------------------------------------------------------------------ ranks + +function RankSection({ + ranks, + deptOptions, + deptName, + isFetching, + refetch, + localized, +}: { + ranks: Rank[]; + deptOptions: { value: string; label: string }[]; + deptName: (id: string) => string; + isFetching: boolean; + refetch: () => void; + localized: (v: Rank["name"]) => string; +}) { + const { t } = useTranslation(); + const { handleError } = useErrorHandler(); + const [createRank, { isLoading: isCreating }] = useCreateRankMutation(); + const [updateRank, { isLoading: isUpdating }] = useUpdateRankMutation(); + const [deleteRank] = useDeleteRankMutation(); + + const [editing, setEditing] = useState(null); + const [showForm, setShowForm] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false); + + const resetForm = useCallback(() => { + setEditing(null); + setShowForm(false); + }, []); + + const form = useForm({ + initialValues: { + departmentId: "", + certificateCategory: "COC" as RankCertificateCategory, + key: "", + nameEn: "", + nameAm: "", + ladderOrder: 0, + }, + validate: { + departmentId: (v) => (!v ? t("configuration.validation.departmentRequired") : null), + key: (v) => (!v ? t("configuration.validation.keyRequired", "Key is required") : null), + nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null), + nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null), + }, + }); + + const openEdit = useCallback( + (rank: Rank) => { + setEditing(rank); + form.setValues({ + departmentId: rank.departmentId, + certificateCategory: rank.certificateCategory, + key: rank.key, + nameEn: rank.name.en ?? "", + nameAm: rank.name.am ?? "", + ladderOrder: rank.ladderOrder, + }); + setShowForm(true); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const handleSubmit = form.onSubmit(async (values) => { + const name = { en: values.nameEn, am: values.nameAm }; + try { + if (editing) { + await updateRank({ + id: editing.id, + departmentId: values.departmentId, + certificateCategory: values.certificateCategory, + key: values.key, + name, + ladderOrder: values.ladderOrder, + }).unwrap(); + notify.success(t("configuration.updated")); + } else { + await createRank({ + departmentId: values.departmentId, + certificateCategory: values.certificateCategory, + key: values.key, + name, + ladderOrder: values.ladderOrder, + }).unwrap(); + notify.success(t("configuration.created")); + } + resetForm(); + form.reset(); + } catch (e) { + handleError(e); + } + }); + + const confirmDelete = useCallback(async () => { + if (!deleteTarget) return; + try { + await deleteRank(deleteTarget.id).unwrap(); + notify.success(t("configuration.deleted")); + closeDelete(); + setDeleteTarget(null); + } catch (e) { + handleError(e); + } + }, [deleteTarget, deleteRank, closeDelete, handleError]); + + const sortedRanks = [...ranks].sort( + (a, b) => + a.departmentId.localeCompare(b.departmentId) || + a.certificateCategory.localeCompare(b.certificateCategory) || + a.ladderOrder - b.ladderOrder, + ); + + const columns: AdvancedColumn[] = [ + { header: t("configuration.department"), cell: ({ row }) => deptName(row.original.departmentId) }, + { header: t("configuration.category", "Ladder"), cell: ({ row }) => row.original.certificateCategory }, + { header: t("configuration.rankOrder", "Rung"), cell: ({ row }) => row.original.ladderOrder }, + { header: t("configuration.key", "Key"), cell: ({ row }) => row.original.key }, + { header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) }, + { + header: "actions", + size: 90, + cell: ({ row }) => ( + + + + + ), + }, + ]; + + return ( + + + {t("configuration.ranks", "Ranks")} + {!showForm && ( + + )} + + + {}} + pageSize={sortedRanks.length || 10} + onPageSizeChange={() => {}} + refresh={refetch} + isLoading={isFetching} + /> + + +
+ + + + + + + + + + + +
+
+ + + + {t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })} + + + + + + +
+ ); +} 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 58fe49258..a28c98db2 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx @@ -23,6 +23,7 @@ import { IconCertificate, IconHash, IconInfoCircle, + IconAnchor, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { @@ -36,6 +37,7 @@ import { import { LocationPage } from "../../../location/pages/LocationPage"; import { CertificationPage } from "../../../certification/pages/CertificationPage"; import { NumberFormatTab } from "../../components/NumberFormatTab"; +import { RankDepartmentTab } from "./RankDepartmentTab"; import { useGetOrganizationsQuery, useGetProfessionsQuery, @@ -401,6 +403,9 @@ export function ConfigurationPage() { }> {t("numberFormat.title", "Number Formats")} + }> + {t("configuration.ranksTab", "Ranks & Departments")} + @@ -418,6 +423,10 @@ export function ConfigurationPage() { + + + + ); diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 6a960d0fa..3e17464ff 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -6,6 +6,7 @@ import type { ApplicationPayment, ApplicationStaff, Attachment, + Department, DocumentRequirement, FormSchemaPalette, FormSectionConfig, @@ -27,6 +28,8 @@ import type { Paginated, QueueCounts, QueueFilter, + Rank, + RankCertificateCategory, RemarkTargetType, SavedQueueView, SchemaIssue, @@ -72,6 +75,8 @@ const TAGS = [ 'SavedView', 'LicenseTemplate', 'DocumentRequirement', + 'Department', + 'Rank', ] as const; const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const; @@ -254,6 +259,75 @@ export const licensingApi = baseApi invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]), }), + // --------------------------------------------------- departments & ranks + /** Every department, for the admin editor. */ + getDepartments: builder.query, void>({ + query: () => ({ url: '/departments' }), + providesTags: () => [listTag('Department')], + }), + + /** Active departments only — the applicant-facing picker. */ + getActiveDepartments: builder.query({ + query: () => ({ url: '/departments/active/list' }), + providesTags: () => [listTag('Department')], + }), + + createDepartment: builder.mutation< + Department, + Partial & { code: string; name: Department['name'] } + >({ + query: (body) => ({ url: '/departments', method: 'POST', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]), + }), + + updateDepartment: builder.mutation>({ + query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]), + }), + + deleteDepartment: builder.mutation({ + query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]), + }), + + /** Every rank, for the admin editor to filter/group by department client-side. */ + getRanks: builder.query, void>({ + query: () => ({ url: '/ranks' }), + providesTags: () => [listTag('Rank')], + }), + + /** One department's ladder for a category, ordered — the applicant wizard's rank picker. */ + getRankLadder: builder.query< + Rank[], + { departmentId: string; certificateCategory: RankCertificateCategory } + >({ + query: (params) => ({ url: '/ranks/ladder', params }), + providesTags: () => [listTag('Rank')], + }), + + createRank: builder.mutation< + Rank, + Partial & { + departmentId: string; + certificateCategory: RankCertificateCategory; + key: string; + name: Rank['name']; + } + >({ + query: (body) => ({ url: '/ranks', method: 'POST', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]), + }), + + updateRank: builder.mutation>({ + query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]), + }), + + deleteRank: builder.mutation({ + query: (id) => ({ url: `/ranks/${id}`, method: 'DELETE' }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]), + }), + // -------------------------------------------------------- application createApplication: builder.mutation< LicenseApplication, @@ -641,6 +715,8 @@ export const licensingApi = baseApi LicenseTemplate, { licenseTypeId: string; + /** Scopes the draft to one rank's certificate. Omit for the type's default design. */ + rankId?: string | null; name: string; hbsSource?: string; pageOptions?: TemplatePageOptions; @@ -654,6 +730,7 @@ export const licensingApi = baseApi LicenseTemplate, { id: string; + rankId?: string | null; name?: string; hbsSource?: string; pageOptions?: TemplatePageOptions; @@ -919,6 +996,16 @@ export const { useCreateDocumentRequirementMutation, useUpdateDocumentRequirementMutation, useDeleteDocumentRequirementMutation, + useGetDepartmentsQuery, + useGetActiveDepartmentsQuery, + useCreateDepartmentMutation, + useUpdateDepartmentMutation, + useDeleteDepartmentMutation, + useGetRanksQuery, + useGetRankLadderQuery, + useCreateRankMutation, + useUpdateRankMutation, + useDeleteRankMutation, useUpdateLicenseValidityMutation, useGetLicenseTypeRequirementsQuery, useCreateApplicationMutation, diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 22bef0df4..89e73ba1e 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -575,9 +575,37 @@ export interface TemplateFieldPlacement { } /** A certificate design authored in the backoffice. */ +/** An STCW seafarer department (Deck, Engine, Catering), backoffice-managed. */ +export interface Department { + id: string; + /** Matches the ESeafarerDepartment value stored elsewhere, e.g. "DECK". */ + code: string; + name: Bilingual; + sortOrder: number; + isActive: boolean; +} + +export type RankCertificateCategory = "COC" | "COP"; + +/** One rung of a CoC/CoP ladder for a department. */ +export interface Rank { + id: string; + departmentId: string; + certificateCategory: RankCertificateCategory; + /** Value stored on License.rank / Certification.rankKey, e.g. "CHIEF_MATE". */ + key: string; + name: Bilingual; + /** Rung position within its department+category ladder. 0 is the entry rank. */ + ladderOrder: number; + sortOrder: number; + isActive: boolean; +} + export interface LicenseTemplate { id: string; licenseTypeId: string; + /** Scopes this design to one rank's certificate. Null = the type's default. */ + rankId?: string | null; name: string; version: number; hbsSource: string; From 302c0242ddd77071b8e69cc41becaa583b466efd Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 24 Aug 2026 07:39:24 +0000 Subject: [PATCH 4/9] Add rank selection and condition handling to certificate designer and requirements --- .../components/DesignerToolbar.tsx | 26 +- .../pages/CertificateDesignerPage.tsx | 36 +- .../components/ConditionBuilder.tsx | 400 ++++++++++++------ .../DocumentRequirementEditorDrawer.tsx | 5 +- .../components/DocumentRequirementsTab.tsx | 9 +- .../features/licensing/licensing.helpers.ts | 21 +- .../lib/features/licensing/licensing.types.ts | 9 +- 7 files changed, 359 insertions(+), 147 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx index 8dec84dfe..5c19085fa 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -1,13 +1,17 @@ import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { useLocalized, type LicenseType } from '@ema-platform/api'; +import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api'; import { groupedTypeOptions } from '../config/designer'; interface Props { licenseTypes: LicenseType[]; typeId: string | null; onTypeChange: (id: string | null) => void; + /** The selected licence type's rank ladder — empty for non-CoC/CoP types. */ + ranks: Rank[]; + rankId: string | null; + onRankChange: (id: string | null) => void; validityMonths: number; onValidityChange: (months: number) => void; currentValidityMonths?: number | null; @@ -22,6 +26,9 @@ export function DesignerToolbar({ licenseTypes, typeId, onTypeChange, + ranks, + rankId, + onRankChange, validityMonths, onValidityChange, currentValidityMonths, @@ -49,6 +56,23 @@ export function DesignerToolbar({ w={340} /> + {/* CoC/CoP only — a rank can carry its own design (e.g. Master's + certificate differs from an OOW's). "Default" (null) is the design + every other rank under the type falls back to. */} + {ranks.length > 0 && ( + ({ value: op, label: op }))} + value={operator} + onChange={(v) => v && setOperator(v as Operator)} + allowDeselect={false} + /> + + {operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && ( + ({ + 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' && ( + + setInValues( + e.currentTarget.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + /> + )} + + + {operator === 'in' && (value.in?.length ?? 0) > 0 && ( + + {(value.in ?? []).map((v, i) => ( + setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))} + title={t('certReq.condition.removeValue', 'Click to remove')} + > + {String(v)} × + + ))} + + )} + + {!target && value.field && ( + + {t( + 'certReq.condition.unknownField', + 'This path is not a field in the current schema yet — it will still be saved as typed.', + )} + + )} + + ); +} + +const EMPTY_ARM: ConditionArm = { field: '', equals: '' }; + /** * Authors one `FieldCondition` (`showWhen` on a section/field, or * `conditionExpression` on a document requirement). @@ -38,6 +232,11 @@ function coerce(raw: string, targetType: string | undefined): string | number | * 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. + * + * "Any of these" switches to authoring several single-field conditions whose + * OR is the real condition — needed when the same logical value can live on + * one of several mutually-exclusive fields (e.g. a rank split by + * department, see FieldCondition.anyOf). */ export function ConditionBuilder({ value, @@ -54,40 +253,35 @@ export function ConditionBuilder({ 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']; + const isAnyOf = Boolean(value?.anyOf); + const arms = (value?.anyOf ?? []) as ConditionArm[]; - function setField(field: string) { - onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) }); + function setArm(i: number, arm: ConditionArm) { + const next = arms.slice(); + next[i] = arm; + onChange({ anyOf: next }); } - 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 addArm() { + onChange({ anyOf: [...arms, { ...EMPTY_ARM }] }); } - 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 removeArm(i: number) { + onChange({ anyOf: arms.filter((_, idx) => idx !== i) }); } - function setInValues(raws: string[]) { - if (!value?.field) return; - onChange({ - field: value.field, - in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[], - }); + function toggleAnyOf(next: boolean) { + if (next) { + // Seed the list from whatever single condition already existed, so + // switching modes doesn't discard work in progress. + const seed: ConditionArm = value?.field ? (value as ConditionArm) : { ...EMPTY_ARM }; + onChange({ anyOf: [seed] }); + } else { + // Same, in reverse — the first arm becomes the single condition. + onChange((arms[0] as ConditionValue) ?? { field: '', equals: '' }); + } } return ( @@ -102,116 +296,58 @@ export function ConditionBuilder({ {active && ( - c.path)} - value={value?.field ?? ''} - onChange={setField} + checked={isAnyOf} + onChange={(e) => toggleAnyOf(e.currentTarget.checked)} /> - - ({ - 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' && ( - ({ - value: o.value, - label: localized(o.label), - }))} + data={selectOptions(field, value as string | undefined, departments, ranks, localized)} value={(value as string) ?? null} onChange={(v) => onChange(field.key, v)} clearable={!field.required} diff --git a/apps/portal/src/app/features/seafarer-registration/components/RegistrationSummary.tsx b/apps/portal/src/app/features/seafarer-registration/components/RegistrationSummary.tsx index 80a72c793..d8a180a29 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/RegistrationSummary.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/RegistrationSummary.tsx @@ -3,6 +3,8 @@ import { SEAFARER_REGISTRATION_FIELD_LABELS, SEAFARER_REGISTRATION_SECTIONS, displaySeafarerAnswer, + useGetActiveDepartmentsQuery, + useLocalized, type Attachment, type SaveSeafarerRegistration, } from '@ema-platform/api'; @@ -16,6 +18,10 @@ export function RegistrationSummary({ answers: SaveSeafarerRegistration; attachments?: Attachment[]; }) { + const localized = useLocalized(); + const { data: departments } = useGetActiveDepartmentsQuery(); + const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) })); + return ( {SEAFARER_REGISTRATION_SECTIONS.map((section) => ( @@ -35,7 +41,9 @@ export function RegistrationSummary({ - {displaySeafarerAnswer(field, answers[field])} + + {displaySeafarerAnswer(field, answers[field], departmentOptions)} + ))} diff --git a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx index d75e7f585..bccc92480 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx @@ -6,6 +6,8 @@ import { GENDER_OPTIONS, HAIR_COLOR_OPTIONS, MARITAL_STATUS_OPTIONS, + useGetActiveDepartmentsQuery, + useLocalized, } from '@ema-platform/api'; import { DateField, @@ -72,8 +74,21 @@ export function IdentityDetailsStep( ); } -/** Step 2 — Identity, Address and Physical Characteristics. */ +/** + * Step 2 — Identity, Address and Physical Characteristics. + * + * The department list is backoffice-managed (see the Ranks & Departments + * configuration tab), so this fetches the live set rather than a fixed + * three — falling back to it only until the query resolves, so the field + * is never an empty flash. + */ export function ApplicantDetailsStep(p: StepProps) { + const localized = useLocalized(); + const { data: departments } = useGetActiveDepartmentsQuery(); + const departmentOptions = + departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ?? + DEPARTMENT_OPTIONS; + return ( @@ -94,7 +109,7 @@ export function ApplicantDetailsStep(p: StepProps) { name="department" label="Department" required - options={DEPARTMENT_OPTIONS} + options={departmentOptions} description="The STCW department you serve in. Determines which certificates, examinations and services apply to you." /> diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 9a5a2b8bd..57a0919c2 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -572,12 +572,15 @@ export interface TemplateFieldPlacement { /** Variable rendered here, or null when the block carries literal `text`. */ variable: string | null; text?: string; + /** Renders as `` when "image" — see TemplateVariable.kind. */ + type?: "text" | "image"; xPct: number; yPct: number; widthPct: number; fontSize?: number; fontWeight?: "normal" | "bold"; - align?: "left" | "center" | "right"; + fontStyle?: "normal" | "italic"; + align?: "left" | "center" | "right" | "justify"; color?: string; } @@ -639,6 +642,8 @@ export interface LicenseTemplate { export interface TemplateVariable { key: string; label: string; + /** "image" means the value is a data URI to place as ``, not text. */ + kind?: "text" | "image"; } export interface Paginated { diff --git a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts index e3406a4f0..1112e2bdf 100644 --- a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts +++ b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts @@ -210,14 +210,22 @@ const OPTION_LABELS: Partial o.value === value)?.label ?? String(value); return String(value); }