From bd65fc0eab9fad000a79f4ce02f9788c19cf6c87 Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 24 Aug 2026 14:15:55 +0000 Subject: [PATCH] Enhance certificate designer and seafarer registration features with image support and dynamic department/rank options --- .../components/BlockPropertiesPanel.tsx | 133 +++++++++++------- .../components/TemplateCanvas.tsx | 26 +++- .../components/TemplateVariableList.tsx | 19 +-- .../config/layout-compiler.ts | 31 +++- .../hooks/useTemplateDraft.ts | 57 +++++--- .../pages/CertificateDesignerPage.tsx | 2 +- .../pages/CertificationPage/index.tsx | 11 +- .../certification/types/certification.ts | 18 --- .../features/exam/pages/ExamPage/index.tsx | 7 +- .../pages/SeafarerRegistrationQueuePage.tsx | 14 +- .../pages/SeafarerRegistrationReviewPage.tsx | 9 +- .../components/ConfigDrivenSection.tsx | 60 +++++++- .../components/RegistrationSummary.tsx | 10 +- .../components/steps.tsx | 19 ++- .../lib/features/licensing/licensing.types.ts | 7 +- .../seafarer-registration.constants.ts | 12 +- 16 files changed, 320 insertions(+), 115 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx index 8e8684350..b0f91cab9 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Button, ColorInput, Group, NumberInput, @@ -11,7 +12,7 @@ import { TextInput, Tooltip, } from '@mantine/core'; -import { IconTrash } from '@tabler/icons-react'; +import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api'; @@ -43,7 +44,17 @@ export function BlockPropertiesPanel({ ); } - const isLiteral = block.variable === null; + const current = block; + const isLiteral = current.variable === null; + const isImage = current.type === 'image'; + const variableByKey = new Map(variables.map((v) => [v.key, v])); + + const selectVariable = (key: string | null) => { + if (!key) return; + const kind: 'text' | 'image' = + variableByKey.get(key)?.kind === 'image' ? 'image' : 'text'; + onChange({ ...current, variable: key, type: kind }); + }; return ( @@ -70,11 +81,9 @@ export function BlockPropertiesPanel({ value={isLiteral ? 'text' : 'variable'} disabled={disabled} onChange={(value) => - onChange( - value === 'text' - ? { ...block, variable: null, text: block.text ?? '' } - : { ...block, variable: variables[0]?.key ?? 'companyName' }, - ) + value === 'text' + ? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' }) + : selectVariable(variables[0]?.key ?? 'companyName') } data={[ { value: 'variable', label: t('designer.blockVariable', 'Variable') }, @@ -94,24 +103,26 @@ export function BlockPropertiesPanel({ label={t('designer.blockVariableLabel', 'Variable')} data={variables.map((variable) => ({ value: variable.key, - label: variable.label, + label: variable.kind === 'image' ? `🖼 ${variable.label}` : variable.label, }))} value={block.variable} - onChange={(value) => onChange({ ...block, variable: value })} + onChange={selectVariable} searchable disabled={disabled} /> )} - onChange({ ...block, fontSize: Number(value) || 14 })} - min={4} - max={200} - disabled={disabled} - /> + {!isImage && ( + onChange({ ...block, fontSize: Number(value) || 14 })} + min={4} + max={200} + disabled={disabled} + /> + )} - - onChange({ ...block, align: value as TemplateFieldPlacement['align'] }) - } - data={[ - { value: 'left', label: t('designer.alignLeft', 'Left') }, - { value: 'center', label: t('designer.alignCenter', 'Centre') }, - { value: 'right', label: t('designer.alignRight', 'Right') }, - ]} - /> + {!isImage && ( + <> + + onChange({ ...block, align: value as TemplateFieldPlacement['align'] }) + } + data={[ + { value: 'left', label: t('designer.alignLeft', 'Left') }, + { value: 'center', label: t('designer.alignCenter', 'Centre') }, + { value: 'right', label: t('designer.alignRight', 'Right') }, + { value: 'justify', label: t('designer.alignJustify', 'Justify') }, + ]} + /> - - onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] }) - } - data={[ - { value: 'normal', label: t('designer.weightNormal', 'Normal') }, - { value: 'bold', label: t('designer.weightBold', 'Bold') }, - ]} - /> + + + + - onChange({ ...block, color: value })} - disabled={disabled} - format="hex" - /> + onChange({ ...block, color: value })} + disabled={disabled} + format="hex" + /> + + )} ); diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx index db0530f2f..d04f360dc 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Paper, Text } from '@mantine/core'; +import { IconPhoto } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api'; @@ -233,6 +234,7 @@ export function TemplateCanvas({ {placements.map((block) => { const isSelected = block.id === selectedId; + const isImage = block.type === 'image'; return (
- {block.variable ? `{{${block.variable}}}` : block.text || ' '} + {isImage ? ( + + ) : block.variable ? ( + `{{${block.variable}}}` + ) : ( + block.text || ' ' + )} {isSelected && !disabled && ( void; - onAddBlock: (key: string) => void; + onAddBlock: (key: string, kind: 'text' | 'image') => void; onAddTextBlock: () => void; } @@ -60,8 +56,13 @@ export function TemplateVariableList({ variant="default" justify="flex-start" disabled={disabled} + leftSection={ + variable.kind === 'image' ? : undefined + } onClick={() => - canvasMode ? onAddBlock(variable.key) : onInsert(variable.key) + canvasMode + ? onAddBlock(variable.key, variable.kind === 'image' ? 'image' : 'text') + : onInsert(variable.key) } > {`{{${variable.key}}}`} diff --git a/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts b/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts index 9488bcd2f..e21bd7a17 100644 --- a/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts +++ b/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts @@ -41,14 +41,43 @@ function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string { return ` \n`; } +/** + * Variable keys the renderer fills with a data URI — the fallback for a + * block placed before `type` existed on it. Keep in sync with + * `IMAGE_VARIABLE_KEYS` in the server's template-variables.ts; a new image + * variable added there should be added here too. + */ +const IMAGE_VARIABLE_KEYS = new Set([ + 'logo', + 'holderPhoto', + 'qrImage', + 'sealImage', + 'signatureImage', + 'seafarerSignature', +]); + +function isImageBlock(block: TemplateFieldPlacement): boolean { + if (block.type) return block.type === 'image'; + return !!block.variable && IMAGE_VARIABLE_KEYS.has(block.variable); +} + function blockHtml(block: TemplateFieldPlacement): string { const x = pct(block.xPct, 0); const y = pct(block.yPct, 0); // Minimum 1%, matching the server compiler: a zero-width block would render // as an invisible sliver rather than as the mistake it is. const width = pct(block.widthPct, 30, 1); + + if (isImageBlock(block) && block.variable) { + // Triple-brace: the value is a data URI, not markup — escaping it turns + // every "&" into "&" and corrupts the src. + const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;object-fit:contain;`; + return ` \n`; + } + const size = block.fontSize ?? 14; const weight = block.fontWeight === 'bold' ? 'bold' : 'normal'; + const style_ = block.fontStyle === 'italic' ? 'italic' : 'normal'; const align = block.align ?? 'left'; const color = escapeHtml(block.color ?? '#111111'); @@ -58,7 +87,7 @@ function blockHtml(block: TemplateFieldPlacement): string { const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;` + - `font-size:${size}px;font-weight:${weight};text-align:${align};color:${color};`; + `font-size:${size}px;font-weight:${weight};font-style:${style_};text-align:${align};color:${color};`; return `
${content}
\n`; } diff --git a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts index 19a28fe34..14abcd909 100644 --- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts +++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts @@ -81,23 +81,46 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { const selectedBlock = placements.find((block) => block.id === selectedBlockId) ?? null; - /** Drops a new block near the top-left, where it is immediately visible. */ - const addBlock = useCallback((variable: string | null, text?: string) => { - const block: TemplateFieldPlacement = { - id: blockId(), - variable, - text, - xPct: 10, - yPct: 10, - widthPct: 30, - fontSize: 14, - fontWeight: 'normal', - align: 'left', - color: '#111111', - }; - setPlacements((prev) => [...prev, block]); - setSelectedBlockId(block.id); - }, []); + /** + * Drops a new block near the top-left, where it is immediately visible. + * + * An image block gets a square-ish default footprint instead of the text + * defaults (fontSize/color/align mean nothing on an ``) — a seal or + * signature dropped at 30% width and no explicit height would otherwise + * stretch to whatever the image's own aspect ratio makes of that width, + * which reads as broken until the author manually resizes it. + */ + const addBlock = useCallback( + (variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => { + const block: TemplateFieldPlacement = + kind === 'image' + ? { + id: blockId(), + variable, + type: 'image', + xPct: 10, + yPct: 10, + widthPct: 15, + } + : { + id: blockId(), + variable, + text, + type: 'text', + xPct: 10, + yPct: 10, + widthPct: 30, + fontSize: 14, + fontWeight: 'normal', + fontStyle: 'normal', + align: 'left', + color: '#111111', + }; + setPlacements((prev) => [...prev, block]); + setSelectedBlockId(block.id); + }, + [], + ); const updateBlock = useCallback((next: TemplateFieldPlacement) => { setPlacements((prev) => 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 73f5f9c91..61d3f5ef9 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -392,7 +392,7 @@ export function CertificateDesignerPage() { disabled={editingLocked} canvasMode={mode === 'canvas'} onInsert={draft.insertVariable} - onAddBlock={(key) => draft.addBlock(key)} + onAddBlock={(key, kind) => draft.addBlock(key, undefined, kind)} onAddTextBlock={() => draft.addBlock(null, 'Text')} /> diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx index e3f9ed214..323703738 100644 --- a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx @@ -4,23 +4,26 @@ import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import {IconPlus} from '@tabler/icons-react'; import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui'; +import { useGetRanksQuery, useLocalized } from '@ema-platform/api'; import { useGetCertificationsQuery, useCreateCertificationMutation, useUpdateCertificationMutation, useDeleteCertificationMutation, } from '../../api/certification-api'; -import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification'; +import { type Certification } from '../../types/certification'; import { certificationColumns } from './columns'; import { certificationActionsColumn } from './actions'; function CertificationForm({ editing, + rankOptions, isSubmitting, onSubmit, onCancel, }: { editing: Certification | null; + rankOptions: { value: string; label: string }[]; isSubmitting: boolean; onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void; onCancel: () => void; @@ -53,7 +56,7 @@ function CertificationForm({ label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')} description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')} placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')} - data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]} + data={rankOptions} value={rankKey} onChange={setRankKey} size="sm" @@ -74,7 +77,10 @@ export function CertificationPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; const { handleError } = useErrorHandler(); + const localized = useLocalized(); const { data, isFetching, isError, refetch } = useGetCertificationsQuery(); + const { data: rankRes } = useGetRanksQuery(); + const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) })); const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable(); const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation(); const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation(); @@ -154,6 +160,7 @@ export function CertificationPage() { {showForm && ( [r.key, localized(r.name)])); const { data, isFetching, isError, refetch } = useGetExamsQuery(); const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable(); const [createExam, { isLoading: isCreating }] = useCreateExamMutation(); @@ -391,7 +394,7 @@ export function ExamPage() { const certOptions = certifications .filter((c) => c.isActive) .map((c) => { - const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label; + const rank = c.rankKey ? rankLabelByKey.get(c.rankKey) : undefined; return { value: c.id, label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale], diff --git a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx index 6c9a7f1b5..4a3787697 100644 --- a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx +++ b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx @@ -7,7 +7,9 @@ import { SEAFARER_REGISTRATION_STATUS_TONES, SEAFARER_REGISTRATION_STATUS_LABELS, displaySeafarerAnswer, + useGetActiveDepartmentsQuery, useListSeafarerRegistrationsQuery, + useLocalized, type SeafarerRegistration, type SeafarerRegistrationStatus, } from '@ema-platform/api'; @@ -28,12 +30,16 @@ export function applicantName(r: Pick(null); const [search, setSearch] = useState(''); const [debouncedSearch] = useDebouncedValue(search, 300); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(PAGE_SIZE); + const { data: departments } = useGetActiveDepartmentsQuery(); + const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) })); + const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({ status: status ?? undefined, search: debouncedSearch || undefined, @@ -69,7 +75,11 @@ export function SeafarerRegistrationQueuePage() { { header: 'Department', accessorKey: 'department', - cell: ({ row }) => {displaySeafarerAnswer('department', row.original.department)}, + cell: ({ row }) => ( + + {displaySeafarerAnswer('department', row.original.department, departmentOptions)} + + ), }, { header: 'Submitted', @@ -100,7 +110,7 @@ export function SeafarerRegistrationQueuePage() { ), }, ], - [showDate], + [showDate, departmentOptions], ); return ( diff --git a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx index d8f99215b..536367886 100644 --- a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx +++ b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx @@ -11,7 +11,9 @@ import { displaySeafarerAnswer, extractErrorMessage, useApproveSeafarerRegistrationMutation, + useGetActiveDepartmentsQuery, useGetSeafarerRegistrationReviewQuery, + useLocalized, useRejectSeafarerRegistrationMutation, useRequestSeafarerRegistrationChangesMutation, } from '@ema-platform/api'; @@ -31,7 +33,10 @@ const DECISION_COPY: Record ({ value: d.code, label: localized(d.name) })); const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation(); const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation(); @@ -166,7 +171,9 @@ export function SeafarerRegistrationReviewPage() { - {displaySeafarerAnswer(field, registration[field])} + + {displaySeafarerAnswer(field, registration[field], departmentOptions)} + ))} diff --git a/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx b/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx index 52e3cebc5..4dddf30bb 100644 --- a/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx +++ b/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx @@ -9,9 +9,14 @@ import { } from '@mantine/core'; import { conditionHolds, + useGetActiveDepartmentsQuery, + useGetRanksQuery, useLocalized, + type Bilingual, + type Department, type FormFieldConfig, type FormSectionConfig, + type Rank, type Vessel, } from '@ema-platform/api'; import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui'; @@ -82,6 +87,41 @@ export function fillFromVessel( } } +/** + * Options for a SELECT field. + * + * A department/rank field's seed `options` are a label cache that goes stale + * the moment a backoffice admin adds a department or rank — the field's + * value is already resolved server-side (profile department, or + * `eligibility.nextRank`), correct either way, but a value missing from a + * stale cache renders as a blank Select. Live-fetched departments/ranks take + * over the labels for these two `source`s; the seed's own `options` still + * cover every other SELECT unchanged. + */ +function selectOptions( + field: FormFieldConfig, + currentValue: string | undefined, + departments: Department[] | undefined, + ranks: Rank[], + localized: (v?: Bilingual) => string, +): { value: string; label: string }[] { + if (field.source === 'profile.seafarerDepartment' && departments) { + return departments.map((d) => ({ value: d.code, label: localized(d.name) })); + } + if (field.source === 'eligibility.nextRank') { + const options = ranks.map((r) => ({ value: r.key, label: localized(r.name) })); + // The resolved rank might not be on THIS field's ladder (rank/rankEngine, + // proficiencyDeck/Engine share one `eligibility.nextRank` source but only + // one is ever populated) — still show it rather than a blank Select. + if (currentValue && !options.some((o) => o.value === currentValue)) { + const known = ranks.find((r) => r.key === currentValue); + options.push({ value: currentValue, label: known ? localized(known.name) : currentValue }); + } + return options; + } + return (field.options ?? []).map((o) => ({ value: o.value, label: localized(o.label) })); +} + /** * Renders one form section from the license type's configuration. * @@ -105,6 +145,21 @@ export function ConfigDrivenSection({ (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), ); + // A department/rank SELECT ships with a hardcoded `options` label list in + // the seed, so a department or rank added later in the backoffice has no + // label there and would render as a blank Select even though the field's + // value (resolved server-side) is correct. Skipped when the section has + // neither kind of field, so most sections never pay for these two queries. + const needsDepartmentLabels = fields.some( + (f) => f.source === 'profile.seafarerDepartment', + ); + const needsRankLabels = fields.some((f) => f.source === 'eligibility.nextRank'); + const { data: departments } = useGetActiveDepartmentsQuery(undefined, { + skip: !needsDepartmentLabels, + }); + const { data: rankRes } = useGetRanksQuery(undefined, { skip: !needsRankLabels }); + const ranks = rankRes?.items ?? []; + return ( {fields.map((field) => { @@ -183,10 +238,7 @@ export function ConfigDrivenSection({ ) : field.type === 'SELECT' ? (