Enhance certificate designer and seafarer registration features with image support and dynamic department/rank options

This commit is contained in:
Nati
2026-08-24 14:15:55 +00:00
parent 4a3ad7c2e6
commit bd65fc0eab
16 changed files with 320 additions and 115 deletions

View File

@@ -1,5 +1,6 @@
import { import {
ActionIcon, ActionIcon,
Button,
ColorInput, ColorInput,
Group, Group,
NumberInput, NumberInput,
@@ -11,7 +12,7 @@ import {
TextInput, TextInput,
Tooltip, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react'; import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api'; 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 ( return (
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
@@ -70,11 +81,9 @@ export function BlockPropertiesPanel({
value={isLiteral ? 'text' : 'variable'} value={isLiteral ? 'text' : 'variable'}
disabled={disabled} disabled={disabled}
onChange={(value) => onChange={(value) =>
onChange( value === 'text'
value === 'text' ? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' })
? { ...block, variable: null, text: block.text ?? '' } : selectVariable(variables[0]?.key ?? 'companyName')
: { ...block, variable: variables[0]?.key ?? 'companyName' },
)
} }
data={[ data={[
{ value: 'variable', label: t('designer.blockVariable', 'Variable') }, { value: 'variable', label: t('designer.blockVariable', 'Variable') },
@@ -94,24 +103,26 @@ export function BlockPropertiesPanel({
label={t('designer.blockVariableLabel', 'Variable')} label={t('designer.blockVariableLabel', 'Variable')}
data={variables.map((variable) => ({ data={variables.map((variable) => ({
value: variable.key, value: variable.key,
label: variable.label, label: variable.kind === 'image' ? `🖼 ${variable.label}` : variable.label,
}))} }))}
value={block.variable} value={block.variable}
onChange={(value) => onChange({ ...block, variable: value })} onChange={selectVariable}
searchable searchable
disabled={disabled} disabled={disabled}
/> />
)} )}
<Group gap="xs" grow> <Group gap="xs" grow>
<NumberInput {!isImage && (
label={t('designer.blockFontSize', 'Font size')} <NumberInput
value={block.fontSize ?? 14} label={t('designer.blockFontSize', 'Font size')}
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })} value={block.fontSize ?? 14}
min={4} onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
max={200} min={4}
disabled={disabled} max={200}
/> disabled={disabled}
/>
)}
<NumberInput <NumberInput
label={t('designer.blockWidth', 'Width (%)')} label={t('designer.blockWidth', 'Width (%)')}
value={block.widthPct} value={block.widthPct}
@@ -149,42 +160,62 @@ export function BlockPropertiesPanel({
/> />
</Group> </Group>
<SegmentedControl {!isImage && (
fullWidth <>
size="xs" <SegmentedControl
value={block.align ?? 'left'} fullWidth
disabled={disabled} size="xs"
onChange={(value) => value={block.align ?? 'left'}
onChange({ ...block, align: value as TemplateFieldPlacement['align'] }) disabled={disabled}
} onChange={(value) =>
data={[ onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
{ value: 'left', label: t('designer.alignLeft', 'Left') }, }
{ value: 'center', label: t('designer.alignCenter', 'Centre') }, data={[
{ value: 'right', label: t('designer.alignRight', 'Right') }, { 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') },
]}
/>
<SegmentedControl <Group gap="xs">
fullWidth <Button
size="xs" size="xs"
value={block.fontWeight ?? 'normal'} variant={block.fontWeight === 'bold' ? 'filled' : 'default'}
disabled={disabled} disabled={disabled}
onChange={(value) => onClick={() =>
onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] }) onChange({
} ...block,
data={[ fontWeight: block.fontWeight === 'bold' ? 'normal' : 'bold',
{ value: 'normal', label: t('designer.weightNormal', 'Normal') }, })
{ value: 'bold', label: t('designer.weightBold', 'Bold') }, }
]} >
/> <IconBold size={14} />
</Button>
<Button
size="xs"
variant={block.fontStyle === 'italic' ? 'filled' : 'default'}
disabled={disabled}
onClick={() =>
onChange({
...block,
fontStyle: block.fontStyle === 'italic' ? 'normal' : 'italic',
})
}
>
<IconItalic size={14} />
</Button>
</Group>
<ColorInput <ColorInput
label={t('designer.blockColor', 'Colour')} label={t('designer.blockColor', 'Colour')}
value={block.color ?? '#111111'} value={block.color ?? '#111111'}
onChange={(value) => onChange({ ...block, color: value })} onChange={(value) => onChange({ ...block, color: value })}
disabled={disabled} disabled={disabled}
format="hex" format="hex"
/> />
</>
)}
</Stack> </Stack>
</Paper> </Paper>
); );

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Paper, Text } from '@mantine/core'; import { Box, Paper, Text } from '@mantine/core';
import { IconPhoto } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api'; import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
@@ -233,6 +234,7 @@ export function TemplateCanvas({
{placements.map((block) => { {placements.map((block) => {
const isSelected = block.id === selectedId; const isSelected = block.id === selectedId;
const isImage = block.type === 'image';
return ( return (
<div <div
key={block.id} key={block.id}
@@ -247,6 +249,7 @@ export function TemplateCanvas({
width: `${block.widthPct}%`, width: `${block.widthPct}%`,
fontSize: block.fontSize ?? 14, fontSize: block.fontSize ?? 14,
fontWeight: block.fontWeight ?? 'normal', fontWeight: block.fontWeight ?? 'normal',
fontStyle: block.fontStyle ?? 'normal',
textAlign: block.align ?? 'left', textAlign: block.align ?? 'left',
color: block.color ?? '#111111', color: block.color ?? '#111111',
cursor: disabled ? 'default' : 'move', cursor: disabled ? 'default' : 'move',
@@ -259,9 +262,30 @@ export function TemplateCanvas({
lineHeight: 1.3, lineHeight: 1.3,
wordWrap: 'break-word', wordWrap: 'break-word',
userSelect: 'none', userSelect: 'none',
// An image block has no real image to show here — the actual
// data URI is only resolved server-side at render/preview
// time — so it gets a fixed square footprint and an icon
// instead of stretching to a text block's shape.
...(isImage
? {
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: isSelected
? 'rgba(34,139,230,0.08)'
: 'var(--mantine-color-gray-1)',
}
: {}),
}} }}
> >
{block.variable ? `{{${block.variable}}}` : block.text || ' '} {isImage ? (
<IconPhoto size={18} color="var(--mantine-color-gray-6)" />
) : block.variable ? (
`{{${block.variable}}}`
) : (
block.text || ' '
)}
{isSelected && !disabled && ( {isSelected && !disabled && (
<span <span

View File

@@ -1,19 +1,15 @@
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core'; import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react'; import { IconPhoto, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TemplateVariable } from '@ema-platform/api';
interface Variable {
key: string;
label: string;
}
interface Props { interface Props {
variables: Variable[]; variables: TemplateVariable[];
disabled: boolean; disabled: boolean;
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */ /** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
canvasMode: boolean; canvasMode: boolean;
onInsert: (key: string) => void; onInsert: (key: string) => void;
onAddBlock: (key: string) => void; onAddBlock: (key: string, kind: 'text' | 'image') => void;
onAddTextBlock: () => void; onAddTextBlock: () => void;
} }
@@ -60,8 +56,13 @@ export function TemplateVariableList({
variant="default" variant="default"
justify="flex-start" justify="flex-start"
disabled={disabled} disabled={disabled}
leftSection={
variable.kind === 'image' ? <IconPhoto size={12} /> : undefined
}
onClick={() => onClick={() =>
canvasMode ? onAddBlock(variable.key) : onInsert(variable.key) canvasMode
? onAddBlock(variable.key, variable.kind === 'image' ? 'image' : 'text')
: onInsert(variable.key)
} }
> >
<Code fz={10}>{`{{${variable.key}}}`}</Code> <Code fz={10}>{`{{${variable.key}}}`}</Code>

View File

@@ -41,14 +41,43 @@ function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string {
return ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\n`; return ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\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 { function blockHtml(block: TemplateFieldPlacement): string {
const x = pct(block.xPct, 0); const x = pct(block.xPct, 0);
const y = pct(block.yPct, 0); const y = pct(block.yPct, 0);
// Minimum 1%, matching the server compiler: a zero-width block would render // Minimum 1%, matching the server compiler: a zero-width block would render
// as an invisible sliver rather than as the mistake it is. // as an invisible sliver rather than as the mistake it is.
const width = pct(block.widthPct, 30, 1); 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 "&amp;" and corrupts the src.
const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;object-fit:contain;`;
return ` <img class="ema-block" style="${style}" src="{{{${block.variable}}}}" alt="" />\n`;
}
const size = block.fontSize ?? 14; const size = block.fontSize ?? 14;
const weight = block.fontWeight === 'bold' ? 'bold' : 'normal'; const weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
const style_ = block.fontStyle === 'italic' ? 'italic' : 'normal';
const align = block.align ?? 'left'; const align = block.align ?? 'left';
const color = escapeHtml(block.color ?? '#111111'); const color = escapeHtml(block.color ?? '#111111');
@@ -58,7 +87,7 @@ function blockHtml(block: TemplateFieldPlacement): string {
const style = const style =
`position:absolute;left:${x}%;top:${y}%;width:${width}%;` + `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 ` <div class="ema-block" style="${style}">${content}</div>\n`; return ` <div class="ema-block" style="${style}">${content}</div>\n`;
} }

View File

@@ -81,23 +81,46 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
const selectedBlock = const selectedBlock =
placements.find((block) => block.id === selectedBlockId) ?? null; 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) => { * Drops a new block near the top-left, where it is immediately visible.
const block: TemplateFieldPlacement = { *
id: blockId(), * An image block gets a square-ish default footprint instead of the text
variable, * defaults (fontSize/color/align mean nothing on an `<img>`) — a seal or
text, * signature dropped at 30% width and no explicit height would otherwise
xPct: 10, * stretch to whatever the image's own aspect ratio makes of that width,
yPct: 10, * which reads as broken until the author manually resizes it.
widthPct: 30, */
fontSize: 14, const addBlock = useCallback(
fontWeight: 'normal', (variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => {
align: 'left', const block: TemplateFieldPlacement =
color: '#111111', kind === 'image'
}; ? {
setPlacements((prev) => [...prev, block]); id: blockId(),
setSelectedBlockId(block.id); 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) => { const updateBlock = useCallback((next: TemplateFieldPlacement) => {
setPlacements((prev) => setPlacements((prev) =>

View File

@@ -392,7 +392,7 @@ export function CertificateDesignerPage() {
disabled={editingLocked} disabled={editingLocked}
canvasMode={mode === 'canvas'} canvasMode={mode === 'canvas'}
onInsert={draft.insertVariable} onInsert={draft.insertVariable}
onAddBlock={(key) => draft.addBlock(key)} onAddBlock={(key, kind) => draft.addBlock(key, undefined, kind)}
onAddTextBlock={() => draft.addBlock(null, 'Text')} onAddTextBlock={() => draft.addBlock(null, 'Text')}
/> />
</Group> </Group>

View File

@@ -4,23 +4,26 @@ import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {IconPlus} from '@tabler/icons-react'; import {IconPlus} from '@tabler/icons-react';
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { useGetRanksQuery, useLocalized } from '@ema-platform/api';
import { import {
useGetCertificationsQuery, useGetCertificationsQuery,
useCreateCertificationMutation, useCreateCertificationMutation,
useUpdateCertificationMutation, useUpdateCertificationMutation,
useDeleteCertificationMutation, useDeleteCertificationMutation,
} from '../../api/certification-api'; } from '../../api/certification-api';
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification'; import { type Certification } from '../../types/certification';
import { certificationColumns } from './columns'; import { certificationColumns } from './columns';
import { certificationActionsColumn } from './actions'; import { certificationActionsColumn } from './actions';
function CertificationForm({ function CertificationForm({
editing, editing,
rankOptions,
isSubmitting, isSubmitting,
onSubmit, onSubmit,
onCancel, onCancel,
}: { }: {
editing: Certification | null; editing: Certification | null;
rankOptions: { value: string; label: string }[];
isSubmitting: boolean; isSubmitting: boolean;
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void; onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
onCancel: () => void; onCancel: () => void;
@@ -53,7 +56,7 @@ function CertificationForm({
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')} 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.')} 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')} placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]} data={rankOptions}
value={rankKey} value={rankKey}
onChange={setRankKey} onChange={setRankKey}
size="sm" size="sm"
@@ -74,7 +77,10 @@ export function CertificationPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am'; const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler(); const { handleError } = useErrorHandler();
const localized = useLocalized();
const { data, isFetching, isError, refetch } = useGetCertificationsQuery(); 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 { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation(); const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation(); const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
@@ -154,6 +160,7 @@ export function CertificationPage() {
{showForm && ( {showForm && (
<CertificationForm <CertificationForm
editing={editing} editing={editing}
rankOptions={rankOptions}
isSubmitting={isCreating || isUpdating} isSubmitting={isCreating || isUpdating}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={resetForm} onCancel={resetForm}

View File

@@ -3,24 +3,6 @@ export interface LocalePair {
am: string; am: string;
} }
/**
* STCW rank an exam certification is for — the join that lets the
* schedule-exam picker offer only sittings valid for an application's rank.
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
* `proficiency` on the backend. Not every certification is on the examined
* ladder, so this stays a plain optional string rather than a required enum.
*/
export const RANK_KEY_OPTIONS = [
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
{ value: 'MASTER', label: 'Master' },
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
] as const;
export interface Certification { export interface Certification {
id: string; id: string;
name: LocalePair; name: LocalePair;

View File

@@ -24,7 +24,7 @@ import {
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui"; import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api"; import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import { RANK_KEY_OPTIONS } from "../../../certification/types/certification"; import { useGetRanksQuery, useLocalized } from "@ema-platform/api";
import { import {
useGetExamsQuery, useGetExamsQuery,
useCreateExamMutation, useCreateExamMutation,
@@ -364,7 +364,10 @@ export function ExamPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { handleError } = useErrorHandler(); const { handleError } = useErrorHandler();
const locale = i18n.language as "en" | "am"; const locale = i18n.language as "en" | "am";
const localized = useLocalized();
const { data: certRes } = useGetCertificationsQuery(); const { data: certRes } = useGetCertificationsQuery();
const { data: rankRes } = useGetRanksQuery();
const rankLabelByKey = new Map((rankRes?.items ?? []).map((r) => [r.key, localized(r.name)]));
const { data, isFetching, isError, refetch } = useGetExamsQuery(); const { data, isFetching, isError, refetch } = useGetExamsQuery();
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable(); const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [createExam, { isLoading: isCreating }] = useCreateExamMutation(); const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
@@ -391,7 +394,7 @@ export function ExamPage() {
const certOptions = certifications const certOptions = certifications
.filter((c) => c.isActive) .filter((c) => c.isActive)
.map((c) => { .map((c) => {
const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label; const rank = c.rankKey ? rankLabelByKey.get(c.rankKey) : undefined;
return { return {
value: c.id, value: c.id,
label: rank ? `${c.name[locale]}${rank}` : c.name[locale], label: rank ? `${c.name[locale]}${rank}` : c.name[locale],

View File

@@ -7,7 +7,9 @@ import {
SEAFARER_REGISTRATION_STATUS_TONES, SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS, SEAFARER_REGISTRATION_STATUS_LABELS,
displaySeafarerAnswer, displaySeafarerAnswer,
useGetActiveDepartmentsQuery,
useListSeafarerRegistrationsQuery, useListSeafarerRegistrationsQuery,
useLocalized,
type SeafarerRegistration, type SeafarerRegistration,
type SeafarerRegistrationStatus, type SeafarerRegistrationStatus,
} from '@ema-platform/api'; } from '@ema-platform/api';
@@ -28,12 +30,16 @@ export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middl
export function SeafarerRegistrationQueuePage() { export function SeafarerRegistrationQueuePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const localized = useLocalized();
const [status, setStatus] = useState<SeafarerRegistrationStatus | null>(null); const [status, setStatus] = useState<SeafarerRegistrationStatus | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300); const [debouncedSearch] = useDebouncedValue(search, 300);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE); 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({ const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
status: status ?? undefined, status: status ?? undefined,
search: debouncedSearch || undefined, search: debouncedSearch || undefined,
@@ -69,7 +75,11 @@ export function SeafarerRegistrationQueuePage() {
{ {
header: 'Department', header: 'Department',
accessorKey: 'department', accessorKey: 'department',
cell: ({ row }) => <Text size="sm">{displaySeafarerAnswer('department', row.original.department)}</Text>, cell: ({ row }) => (
<Text size="sm">
{displaySeafarerAnswer('department', row.original.department, departmentOptions)}
</Text>
),
}, },
{ {
header: 'Submitted', header: 'Submitted',
@@ -100,7 +110,7 @@ export function SeafarerRegistrationQueuePage() {
), ),
}, },
], ],
[showDate], [showDate, departmentOptions],
); );
return ( return (

View File

@@ -11,7 +11,9 @@ import {
displaySeafarerAnswer, displaySeafarerAnswer,
extractErrorMessage, extractErrorMessage,
useApproveSeafarerRegistrationMutation, useApproveSeafarerRegistrationMutation,
useGetActiveDepartmentsQuery,
useGetSeafarerRegistrationReviewQuery, useGetSeafarerRegistrationReviewQuery,
useLocalized,
useRejectSeafarerRegistrationMutation, useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation, useRequestSeafarerRegistrationChangesMutation,
} from '@ema-platform/api'; } from '@ema-platform/api';
@@ -31,7 +33,10 @@ const DECISION_COPY: Record<Decision, { title: string; label: string; color: str
export function SeafarerRegistrationReviewPage() { export function SeafarerRegistrationReviewPage() {
const { id = '' } = useParams(); const { id = '' } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const localized = useLocalized();
const { data, isLoading, error } = useGetSeafarerRegistrationReviewQuery(id, { skip: !id }); const { data, isLoading, error } = useGetSeafarerRegistrationReviewQuery(id, { skip: !id });
const { data: departments } = useGetActiveDepartmentsQuery();
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation(); const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation();
const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation(); const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
@@ -166,7 +171,9 @@ export function SeafarerRegistrationReviewPage() {
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm">{displaySeafarerAnswer(field, registration[field])}</Text> <Text size="sm">
{displaySeafarerAnswer(field, registration[field], departmentOptions)}
</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -9,9 +9,14 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { import {
conditionHolds, conditionHolds,
useGetActiveDepartmentsQuery,
useGetRanksQuery,
useLocalized, useLocalized,
type Bilingual,
type Department,
type FormFieldConfig, type FormFieldConfig,
type FormSectionConfig, type FormSectionConfig,
type Rank,
type Vessel, type Vessel,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui'; 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. * 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, 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 ( return (
<Grid> <Grid>
{fields.map((field) => { {fields.map((field) => {
@@ -183,10 +238,7 @@ export function ConfigDrivenSection({
) : field.type === 'SELECT' ? ( ) : field.type === 'SELECT' ? (
<Select <Select
{...common} {...common}
data={(field.options ?? []).map((o) => ({ data={selectOptions(field, value as string | undefined, departments, ranks, localized)}
value: o.value,
label: localized(o.label),
}))}
value={(value as string) ?? null} value={(value as string) ?? null}
onChange={(v) => onChange(field.key, v)} onChange={(v) => onChange(field.key, v)}
clearable={!field.required} clearable={!field.required}

View File

@@ -3,6 +3,8 @@ import {
SEAFARER_REGISTRATION_FIELD_LABELS, SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_SECTIONS, SEAFARER_REGISTRATION_SECTIONS,
displaySeafarerAnswer, displaySeafarerAnswer,
useGetActiveDepartmentsQuery,
useLocalized,
type Attachment, type Attachment,
type SaveSeafarerRegistration, type SaveSeafarerRegistration,
} from '@ema-platform/api'; } from '@ema-platform/api';
@@ -16,6 +18,10 @@ export function RegistrationSummary({
answers: SaveSeafarerRegistration; answers: SaveSeafarerRegistration;
attachments?: Attachment[]; attachments?: Attachment[];
}) { }) {
const localized = useLocalized();
const { data: departments } = useGetActiveDepartmentsQuery();
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
return ( return (
<Stack gap="md"> <Stack gap="md">
{SEAFARER_REGISTRATION_SECTIONS.map((section) => ( {SEAFARER_REGISTRATION_SECTIONS.map((section) => (
@@ -35,7 +41,9 @@ export function RegistrationSummary({
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text> <Text size="sm">
{displaySeafarerAnswer(field, answers[field], departmentOptions)}
</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -6,6 +6,8 @@ import {
GENDER_OPTIONS, GENDER_OPTIONS,
HAIR_COLOR_OPTIONS, HAIR_COLOR_OPTIONS,
MARITAL_STATUS_OPTIONS, MARITAL_STATUS_OPTIONS,
useGetActiveDepartmentsQuery,
useLocalized,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { import {
DateField, 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) { 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 ( return (
<Stack gap="lg"> <Stack gap="lg">
<SectionTitle title="Identity" /> <SectionTitle title="Identity" />
@@ -94,7 +109,7 @@ export function ApplicantDetailsStep(p: StepProps) {
name="department" name="department"
label="Department" label="Department"
required required
options={DEPARTMENT_OPTIONS} options={departmentOptions}
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you." description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
/> />
</Grid> </Grid>

View File

@@ -572,12 +572,15 @@ export interface TemplateFieldPlacement {
/** Variable rendered here, or null when the block carries literal `text`. */ /** Variable rendered here, or null when the block carries literal `text`. */
variable: string | null; variable: string | null;
text?: string; text?: string;
/** Renders as `<img>` when "image" — see TemplateVariable.kind. */
type?: "text" | "image";
xPct: number; xPct: number;
yPct: number; yPct: number;
widthPct: number; widthPct: number;
fontSize?: number; fontSize?: number;
fontWeight?: "normal" | "bold"; fontWeight?: "normal" | "bold";
align?: "left" | "center" | "right"; fontStyle?: "normal" | "italic";
align?: "left" | "center" | "right" | "justify";
color?: string; color?: string;
} }
@@ -639,6 +642,8 @@ export interface LicenseTemplate {
export interface TemplateVariable { export interface TemplateVariable {
key: string; key: string;
label: string; label: string;
/** "image" means the value is a data URI to place as `<img>`, not text. */
kind?: "text" | "image";
} }
export interface Paginated<T> { export interface Paginated<T> {

View File

@@ -210,14 +210,22 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
bloodType: BLOOD_TYPE_OPTIONS, bloodType: BLOOD_TYPE_OPTIONS,
}; };
/** Display value for one answer: option label for enums, "—" when blank. */ /**
* Display value for one answer: option label for enums, "—" when blank.
*
* `departmentOptions` overrides the hardcoded `DEPARTMENT_OPTIONS` fallback
* for the `department` field — the live list from `GET /departments`, so a
* department added in the backoffice after this constants file was written
* still gets its name instead of falling back to the raw code.
*/
export function displaySeafarerAnswer( export function displaySeafarerAnswer(
field: keyof SeafarerRegistrationAnswers, field: keyof SeafarerRegistrationAnswers,
value: unknown, value: unknown,
departmentOptions?: { value: string; label: string }[],
): string { ): string {
if (value === null || value === undefined || value === '') return '—'; if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (typeof value === 'boolean') return value ? 'Yes' : 'No';
const options = OPTION_LABELS[field]; const options = field === 'department' && departmentOptions ? departmentOptions : OPTION_LABELS[field];
if (options) return options.find((o) => o.value === value)?.label ?? String(value); if (options) return options.find((o) => o.value === value)?.label ?? String(value);
return String(value); return String(value);
} }