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/TemplateBackgroundPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx index 70e603189..3c5bbea86 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx @@ -23,6 +23,10 @@ interface Props { onLogoPlacementChange: (placement: TemplateLogoPlacement) => void; landscape: boolean; onLandscapeChange: (landscape: boolean) => void; + pageWidth: string; + onPageWidthChange: (width: string) => void; + pageHeight: string; + onPageHeightChange: (height: string) => void; disabled: boolean; } @@ -51,6 +55,10 @@ export function TemplateBackgroundPanel({ onLogoPlacementChange, landscape, onLandscapeChange, + pageWidth, + onPageWidthChange, + pageHeight, + onPageHeightChange, disabled, }: Props) { const { t } = useTranslation(); @@ -91,9 +99,40 @@ export function TemplateBackgroundPanel({ ]} /> - {landscape - ? t('designer.a4Landscape', 'A4 — 297 × 210 mm') - : t('designer.a4Portrait', 'A4 — 210 × 297 mm')} + {pageWidth && pageHeight + ? t('designer.customSize', 'Custom size — see below') + : landscape + ? t('designer.a4Landscape', 'A4 — 297 × 210 mm') + : t('designer.a4Portrait', 'A4 — 210 × 297 mm')} + + + +
+ + {t('designer.customPageSize', 'Custom page size')} + + + onPageWidthChange(e.currentTarget.value)} + disabled={disabled} + w={100} + /> + × + onPageHeightChange(e.currentTarget.value)} + disabled={disabled} + w={100} + /> + + + {t( + 'designer.customSizeHint', + 'e.g. 4.92in × 3.46in. Leave both blank to use A4. Overrides orientation\'s A4 size when set.', + )}
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..57c3912b7 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'; @@ -8,6 +9,8 @@ interface Props { logoUrl: string; logoPlacement: TemplateLogoPlacement; landscape: boolean; + pageWidth?: string; + pageHeight?: string; placements: TemplateFieldPlacement[]; selectedId: string | null; onSelect: (id: string | null) => void; @@ -15,9 +18,17 @@ interface Props { disabled: boolean; } -/** A4 aspect ratio, the only page size the renderer is configured for. */ +/** A4 aspect ratio, the fallback for a version with no custom page size. */ const A4_RATIO = 297 / 210; +/** Parses a CSS length like "4.92in" or "125mm" into a unitless number, unit-agnostic — only the ratio between width and height matters here. */ +function parseLength(value: string): number | null { + const match = value.trim().match(/^([\d.]+)/); + if (!match) return null; + const n = Number(match[1]); + return Number.isFinite(n) && n > 0 ? n : null; +} + const LOGO_CORNER_STYLE: Record React.CSSProperties> = { TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }), TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }), @@ -53,6 +64,8 @@ export function TemplateCanvas({ logoUrl, logoPlacement, landscape, + pageWidth, + pageHeight, placements, selectedId, onSelect, @@ -180,6 +193,18 @@ export function TemplateCanvas({ logoPlacement.offsetPct ?? 5, ); + // A custom size already states its own orientation (4.92in × 3.46in is + // landscape on its own), so it is used as-is rather than flipped again by + // `landscape` — that flag only disambiguates the A4 fallback below. + const customWidth = pageWidth ? parseLength(pageWidth) : null; + const customHeight = pageHeight ? parseLength(pageHeight) : null; + const aspectRatio = + customWidth && customHeight + ? customWidth / customHeight + : landscape + ? A4_RATIO + : 1 / A4_RATIO; + return ( @@ -195,7 +220,7 @@ export function TemplateCanvas({ style={{ position: 'relative', width: '100%', - aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO), + aspectRatio: String(aspectRatio), background: '#ffffff', border: '1px solid var(--mantine-color-gray-4)', overflow: 'hidden', @@ -233,6 +258,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/designer.ts b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts index f6fd5abae..e9f4d2892 100644 --- a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts +++ b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts @@ -11,8 +11,16 @@ export const STATUS_COLOR: Record = { ARCHIVED: 'dark', }; -/** Page options sent with every save and preview — A4, background printed. */ -export function pageOptionsFor(landscape: boolean) { +/** + * Page options sent with every save and preview. + * + * A4 unless the version carries an explicit page size — set for documents + * like the Seaman Book, whose ICAO 9303 passport-booklet dimensions have no + * named `format` preset. `width`/`height` win over `format` in Puppeteer, so + * a custom size is sent alone rather than alongside `format: 'A4'`. + */ +export function pageOptionsFor(landscape: boolean, size?: { width: string; height: string }) { + if (size) return { width: size.width, height: size.height, landscape, printBackground: true }; return { format: 'A4' as const, landscape, printBackground: true }; } 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..abfd1ad01 100644 --- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts +++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts @@ -22,6 +22,11 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { const [source, setSource] = useState(''); const [name, setName] = useState(''); const [landscape, setLandscape] = useState(true); + // Empty string means "use the A4 default" — only a version whose + // pageOptions already carries a custom size (e.g. the Seaman Book) starts + // with these populated. + const [pageWidth, setPageWidth] = useState(''); + const [pageHeight, setPageHeight] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); const [logoUrl, setLogoUrl] = useState(''); const [logoPlacement, setLogoPlacement] = useState({}); @@ -49,6 +54,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { setSource(selected.hbsSource); setName(selected.name); setLandscape(selected.pageOptions?.landscape ?? true); + setPageWidth(selected.pageOptions?.width ?? ''); + setPageHeight(selected.pageOptions?.height ?? ''); setBackgroundUrl(selected.backgroundUrl ?? ''); setLogoUrl(selected.logoUrl ?? ''); setLogoPlacement(selected.logoPlacement ?? {}); @@ -70,6 +77,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { (source !== selected?.hbsSource || name !== selected?.name || landscape !== (selected?.pageOptions?.landscape ?? true) || + pageWidth !== (selected?.pageOptions?.width ?? '') || + pageHeight !== (selected?.pageOptions?.height ?? '') || backgroundUrl !== (selected?.backgroundUrl ?? '') || logoUrl !== (selected?.logoUrl ?? '') || logoPlacementChanged || @@ -81,23 +90,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) => @@ -137,6 +169,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { setName, landscape, setLandscape, + pageWidth, + setPageWidth, + pageHeight, + setPageHeight, backgroundUrl, setBackgroundUrl, logoUrl, 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 c2714f856..975e03459 100644 --- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts +++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplatePreview.ts @@ -9,6 +9,8 @@ interface PreviewArgs { hbsSource: string; licenseTypeId: string | null; landscape: boolean; + pageWidth?: string; + pageHeight?: string; } /** @@ -21,7 +23,7 @@ export function useTemplatePreview() { const [previewUrl, setPreviewUrl] = useState(null); const open = useCallback( - async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => { + async ({ hbsSource, licenseTypeId, landscape, pageWidth, pageHeight }: PreviewArgs) => { try { // The preview returns a PDF stream, not JSON, so it bypasses RTK Query // and calls the API directly — which means spelling out the base URL and @@ -36,7 +38,10 @@ export function useTemplatePreview() { body: JSON.stringify({ hbsSource, licenseTypeId, - pageOptions: pageOptionsFor(landscape), + pageOptions: pageOptionsFor( + landscape, + pageWidth && pageHeight ? { width: pageWidth, height: pageHeight } : undefined, + ), }), }); if (!response.ok) throw new Error(await response.text()); 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..f9401f3e4 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -221,6 +221,10 @@ export function CertificateDesignerPage() { onLogoPlacementChange={draft.setLogoPlacement} landscape={draft.landscape} onLandscapeChange={draft.setLandscape} + pageWidth={draft.pageWidth} + onPageWidthChange={draft.setPageWidth} + pageHeight={draft.pageHeight} + onPageHeightChange={draft.setPageHeight} disabled={editingLocked} /> @@ -278,6 +282,8 @@ export function CertificateDesignerPage() { logoUrl={draft.logoUrl} logoPlacement={draft.logoPlacement} landscape={draft.landscape} + pageWidth={draft.pageWidth} + pageHeight={draft.pageHeight} placements={draft.placements} selectedId={draft.selectedBlockId} onSelect={draft.setSelectedBlockId} @@ -345,6 +351,8 @@ export function CertificateDesignerPage() { : draft.source, licenseTypeId: typeId, landscape: draft.landscape, + pageWidth: draft.pageWidth || undefined, + pageHeight: draft.pageHeight || undefined, }) } onSave={() => @@ -357,7 +365,12 @@ export function CertificateDesignerPage() { // canvas layout is present, so sending the stale source // alongside it would only fight that. hbsSource: draft.usesCanvas ? undefined : draft.source, - pageOptions: pageOptionsFor(draft.landscape), + pageOptions: pageOptionsFor( + draft.landscape, + draft.pageWidth && draft.pageHeight + ? { width: draft.pageWidth, height: draft.pageHeight } + : undefined, + ), backgroundUrl: draft.backgroundUrl || undefined, logoUrl: draft.logoUrl || undefined, logoPlacement: draft.logoPlacement, @@ -392,7 +405,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/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx index 5baba8e99..1efa584e7 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -94,10 +94,16 @@ export function DocumentRequirementEditorDrawer({ return; } if (!draft.name.en?.trim()) return; + // A condition is either a single-field check or an anyOf list — the CoP + // watch_rating_certificate requirement is seeded with anyOf and no field. const hasCondition = Boolean(draft.conditionExpression?.field) || Boolean(draft.conditionExpression?.anyOf?.length); - if (draft.mode === 'CONDITIONAL' && !hasCondition) { + if ( + draft.mode === 'CONDITIONAL' && + !hasCondition && + !draft.conditionExpression?.anyOf?.length + ) { setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition')); return; } 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(); @@ -418,7 +421,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/license-review/components/AssignDialog.tsx b/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx new file mode 100644 index 000000000..9afca696c --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import { Modal, Select, Stack, Text, Textarea } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useGetAssignableOfficersQuery } from '@ema-platform/api'; +import { ModalFooter } from '@ema-platform/ui'; +import { Button } from '@mantine/core'; + +export type AssignKind = 'review' | 'inspection'; + +interface AssignDialogProps { + opened: boolean; + onClose: () => void; + /** Which stage is being handed out — changes the wording, not the mechanics. */ + kind: AssignKind; + /** Reference of the application being dispatched, shown for confirmation. */ + applicationNumber?: string; + loading?: boolean; + onConfirm: (officerId: string, remark?: string) => void; +} + +/** + * The team leader handing work to an employee. + * + * One dialog for both stages because the decision is identical — pick a person, + * optionally say why — and two near-identical modals would drift apart. The + * `kind` only selects wording. + * + * Confirm stays disabled until someone is picked: an assignment with no + * assignee is the one mistake this dialog exists to prevent. + */ +export function AssignDialog({ + opened, + onClose, + kind, + applicationNumber, + loading, + onConfirm, +}: AssignDialogProps) { + const { t } = useTranslation(); + const { data: officers = [], isLoading } = useGetAssignableOfficersQuery(); + const [officerId, setOfficerId] = useState(null); + const [remark, setRemark] = useState(''); + + // Reopening for a different application must not offer the previous + // dialog's answers as if they had been chosen for this one. + useEffect(() => { + if (opened) { + setOfficerId(null); + setRemark(''); + } + }, [opened]); + + const title = + kind === 'review' + ? t('queue.assignReview', 'Assign document review') + : t('queue.assignInspection', 'Assign inspection'); + + return ( + + + {applicationNumber && ( + + {applicationNumber} + + )} + +