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 d04f360dc..57c3912b7 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx @@ -9,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; @@ -16,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%)' }), @@ -54,6 +64,8 @@ export function TemplateCanvas({ logoUrl, logoPlacement, landscape, + pageWidth, + pageHeight, placements, selectedId, onSelect, @@ -181,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 ( @@ -196,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', 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/hooks/useTemplateDraft.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts index 14abcd909..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 || @@ -160,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 61d3f5ef9..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, 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} + + )} + +