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 b0f91cab9..3ea42c790 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx @@ -135,6 +135,21 @@ export function BlockPropertiesPanel({ /> + { + const index = Math.max(0, Math.min(63, Math.floor(Number(value) || 1) - 1)); + // Omitted on the first page so a single-page design stays as it was. + const { page: _page, ...rest } = block; + onChange(index > 0 ? { ...rest, page: index } : rest); + }} + min={1} + max={64} + disabled={disabled} + /> + {t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')} 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 57c3912b7..1638834ec 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Box, Paper, Text } from '@mantine/core'; -import { IconPhoto } from '@tabler/icons-react'; +import { Box, Button, Group, Paper, Text } from '@mantine/core'; +import { IconPhoto, IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api'; +import { pageIndexOf } from '../config/layout-compiler'; interface Props { backgroundUrl: string; @@ -16,6 +17,11 @@ interface Props { onSelect: (id: string | null) => void; onChange: (placements: TemplateFieldPlacement[]) => void; disabled: boolean; + /** Zero-based page being shown; only that page's blocks are drawn. */ + page: number; + pageCount: number; + onPageChange: (page: number) => void; + onAddPage: () => void; } /** A4 aspect ratio, the fallback for a version with no custom page size. */ @@ -71,9 +77,14 @@ export function TemplateCanvas({ onSelect, onChange, disabled, + page, + pageCount, + onPageChange, + onAddPage, }: Props) { const { t } = useTranslation(); const pageRef = useRef(null); + const visible = placements.filter((block) => pageIndexOf(block) === page); const [drag, setDrag] = useState(null); // Held in a ref as well: the pointer handlers are bound to the window for the @@ -214,6 +225,31 @@ export function TemplateCanvas({ )} + {/* One tab per sheet. A single-page certificate shows only "Page 1" and + the add button; a booklet (the Seaman Book) switches between its + pages here, each positioning its own blocks. */} + + {Array.from({ length: pageCount }, (_, index) => ( + + ))} + + + onSelect(null)} @@ -256,7 +292,7 @@ export function TemplateCanvas({ /> )} - {placements.map((block) => { + {visible.map((block) => { const isSelected = block.id === selectedId; const isImage = block.type === 'image'; return ( diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx index fa9c366c5..36175a7b7 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx @@ -51,7 +51,7 @@ export function TemplateEditor({ {t( 'designer.publishedLocked', - 'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.', + 'This version is live and cannot be edited — certificates are issued from it. Create a new version to make changes.', )} 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 e21bd7a17..d7ee27a8e 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 @@ -102,7 +102,16 @@ export function compileLayoutToHbs(input: { ? ` \n` : ''; - const blocks = input.fieldPlacements.map(blockHtml).join(''); + const logo = logoHtml(input.logoUrl, input.logoPlacement); + + // Same grouping as the server: one page box per page index, each carrying + // the background and logo, so a booklet previews as the sheets it prints. + const body = groupByPage(input.fieldPlacements) + .map( + (blocks) => + `
\n${background}${logo}${blocks.map(blockHtml).join('')}
\n`, + ) + .join(''); return ` @@ -113,6 +122,10 @@ export function compileLayoutToHbs(input: { html, body { margin: 0; padding: 0; height: 100%; } body { font-family: "Helvetica Neue", Arial, sans-serif; } .ema-page { position: relative; width: 100%; height: 100vh; overflow: hidden; } + /* Every page but the last starts a new sheet. Applied to the element + rather than between them so a single-page design prints one sheet + with no trailing blank. */ + .ema-page + .ema-page { page-break-before: always; break-before: page; } .ema-background { position: absolute; inset: 0; width: 100%; height: 100%; @@ -122,9 +135,28 @@ export function compileLayoutToHbs(input: { -
-${background}${logoHtml(input.logoUrl, input.logoPlacement)}${blocks}
- +${body} `; } + +/** Upper bound on pages, matching the server, so a bad value cannot explode the preview. */ +const MAX_PAGE_INDEX = 63; + +/** A block's page, defaulting to the first and refusing a nonsense value. */ +export function pageIndexOf(block: Pick): number { + const page = block.page; + if (typeof page !== 'number' || !Number.isFinite(page) || page < 0) return 0; + return Math.min(Math.floor(page), MAX_PAGE_INDEX); +} + +/** + * Blocks split into pages, in page order, with no gaps — an empty page in + * the middle of a booklet is a deliberate blank verso and is kept. + */ +export function groupByPage(placements: TemplateFieldPlacement[]): TemplateFieldPlacement[][] { + const highest = placements.reduce((max, block) => Math.max(max, pageIndexOf(block)), 0); + const pages: TemplateFieldPlacement[][] = Array.from({ length: highest + 1 }, () => []); + for (const block of placements) pages[pageIndexOf(block)].push(block); + return pages; +} 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 abfd1ad01..f065337c0 100644 --- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts +++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts @@ -4,6 +4,7 @@ import type { TemplateFieldPlacement, TemplateLogoPlacement, } from '@ema-platform/api'; +import { pageIndexOf } from '../config/layout-compiler'; /** Stable ids for new blocks; `crypto.randomUUID` is not in every test env. */ function blockId(): string { @@ -32,6 +33,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { const [logoPlacement, setLogoPlacement] = useState({}); const [placements, setPlacements] = useState([]); const [selectedBlockId, setSelectedBlockId] = useState(null); + // The page the canvas is showing. Pages exist only through the blocks on + // them, so a freshly added page is held here until its first block lands. + const [currentPage, setCurrentPage] = useState(0); + const [extraPages, setExtraPages] = useState(0); const editorRef = useRef(null); const selected = useMemo( @@ -61,8 +66,19 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { setLogoPlacement(selected.logoPlacement ?? {}); setPlacements(selected.fieldPlacements ?? []); setSelectedBlockId(null); + setCurrentPage(0); + setExtraPages(0); }, [selected]); + /** Pages the design spans: the highest block page, plus any added and still empty. */ + const pageCount = + placements.reduce((max, block) => Math.max(max, pageIndexOf(block)), 0) + 1 + extraPages; + + const addPage = useCallback(() => { + setExtraPages((n) => n + 1); + setCurrentPage(pageCount); + }, [pageCount]); + const isPublished = selected?.status === 'PUBLISHED'; // Compared as JSON because both are plain data the server round-trips; a @@ -101,12 +117,16 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { */ const addBlock = useCallback( (variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => { + // Placed on the page being viewed; `page` is omitted on the first so a + // single-page design stays byte-for-byte what it was before booklets. + const page = currentPage > 0 ? { page: currentPage } : {}; const block: TemplateFieldPlacement = kind === 'image' ? { id: blockId(), variable, type: 'image', + ...page, xPct: 10, yPct: 10, widthPct: 15, @@ -116,6 +136,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { variable, text, type: 'text', + ...page, xPct: 10, yPct: 10, widthPct: 30, @@ -128,7 +149,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { setPlacements((prev) => [...prev, block]); setSelectedBlockId(block.id); }, - [], + [currentPage], ); const updateBlock = useCallback((next: TemplateFieldPlacement) => { @@ -185,6 +206,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) { setSelectedBlockId, selectedBlock, usesCanvas, + currentPage, + setCurrentPage, + pageCount, + addPage, addBlock, updateBlock, deleteBlock, 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 b46d68689..d775efa07 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -4,6 +4,7 @@ import { Button, Container, Group, + Modal, Paper, Stack, Tabs, @@ -23,7 +24,6 @@ import { useArchiveLicenseTemplateMutation, useCreateLicenseTemplateMutation, useDeleteLicenseTemplateMutation, - useGetBuiltInTemplateQuery, useGetLicenseTemplatesQuery, useGetLicenseTypesQuery, useGetRanksQuery, @@ -31,7 +31,7 @@ import { usePublishLicenseTemplateMutation, useUpdateLicenseTemplateMutation, } from '@ema-platform/api'; -import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui'; +import { EmptyState, ErrorState, ModalFooter, PageHeader, PdfPreviewModal } from '@ema-platform/ui'; import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth'; import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel'; import { DesignerToolbar } from '../components/DesignerToolbar'; @@ -62,6 +62,9 @@ export function CertificateDesignerPage() { const { can } = usePermissions(); const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]); + // Its own permission on the server, so the button follows it rather than + // showing to an editor who would only be refused on click. + const canCreate = can([PERMISSIONS.CREATE_TEMPLATE]); const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]); const { data: licenseTypes } = useGetLicenseTypesQuery(); @@ -79,8 +82,10 @@ export function CertificateDesignerPage() { // default both come back, so the version list is scoped to whichever the // toolbar has selected. const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId); - const { data: variables = [] } = useGetTemplateVariablesQuery(); - const { data: builtIn } = useGetBuiltInTemplateQuery(); + // Per type, so the palette also lists this form's own answers. + const { data: variables = [] } = useGetTemplateVariablesQuery( + typeId ? { licenseTypeId: typeId } : undefined, + ); const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation(); const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation(); @@ -94,25 +99,23 @@ export function CertificateDesignerPage() { const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(''); + const [deleteOpen, setDeleteOpen] = useState(false); const [mode, setMode] = useState<'canvas' | 'source'>('canvas'); const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); // A rank ladder only exists for CoC/CoP — every other licence type designs - // one certificate for everyone who holds it. Keyed on `key`, not - // `certificateCategory`: that STCW-mapping column is unset on the seeded - // CoC/CoP rows (it's authored later, per StcwMappingPanel), while `key` is - // the stable identity CertificateEligibilityService itself branches on. - // CoC/CoP are each a single LicenseType spanning every department's ladder - // (the applicant's own department, not the type, decides which ladder they - // climb), so the picker offers every rank in the ladder across all - // departments rather than one department's. + // one certificate for everyone who holds it. Keyed on the configured + // `certificateCategory`, the same column the server's rank resolution reads, + // so a type an administrator marks as a CoC/CoP on the Behaviour tab gets a + // rank picker without a code change. CoC/CoP are each a single LicenseType + // spanning every department's ladder (the applicant's own department, not + // the type, decides which ladder they climb), so the picker offers every + // rank across all departments rather than one department's. const rankCategory: 'COC' | 'COP' | null = - selectedType?.key === 'CERTIFICATE_OF_COMPETENCY' - ? 'COC' - : selectedType?.key === 'CERTIFICATE_OF_PROFICIENCY' - ? 'COP' - : null; + selectedType?.certificateCategory === 'COC' || selectedType?.certificateCategory === 'COP' + ? selectedType.certificateCategory + : null; const isRankScoped = rankCategory !== null; const { data: allRanks } = useGetRanksQuery(undefined, { skip: !isRankScoped }); const ranks = (allRanks?.items ?? []) @@ -164,7 +167,7 @@ export function CertificateDesignerPage() { }} validityMonths={selectedType?.validityMonths ?? 12} validityDays={selectedType?.validityDays ?? null} - canEdit={canEdit} + canEdit={canCreate} onNewVersion={startNewVersion} /> @@ -183,7 +186,7 @@ export function CertificateDesignerPage() { 'Certificates currently use the built-in layout. Create a version to take control of it.', )} action={ - canEdit + canCreate ? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion } : undefined } @@ -228,10 +231,10 @@ export function CertificateDesignerPage() { {t( 'designer.liveDesignBody', - 'Certificates have been issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.', + 'Certificates are issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.', )} - {canEdit && ( + {canCreate && ( + + + + + [listTag('LicenseTemplate')], }), - getTemplateVariables: builder.query({ - query: () => ({ url: '/license-templates/variables' }), + /** + * With a licence type, the palette also carries that type's own form + * answers (`form.
.`), which issuance hands every design. + */ + getTemplateVariables: builder.query({ + query: (args) => ({ + url: '/license-templates/variables', + params: args?.licenseTypeId ? { licenseTypeId: args.licenseTypeId } : {}, + }), }), getBuiltInTemplate: builder.query<{ hbsSource: string }, void>({ @@ -902,8 +909,9 @@ export const licensingApi = baseApi name?: string; hbsSource?: string; pageOptions?: TemplatePageOptions; - backgroundUrl?: string; - logoUrl?: string; + /** Null removes the artwork; undefined leaves it as stored. */ + backgroundUrl?: string | null; + logoUrl?: string | null; logoPlacement?: TemplateLogoPlacement; fieldPlacements?: TemplateFieldPlacement[]; } diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index e20c4029a..a666b20d9 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -727,6 +727,12 @@ export interface TemplateFieldPlacement { text?: string; /** Renders as `` when "image" — see TemplateVariable.kind. */ type?: "text" | "image"; + /** + * Zero-based page the block sits on. Absent means the first page, which is + * what every single-page design already says by saying nothing. A booklet + * (the Seaman Book) places its blocks across several. + */ + page?: number; xPct: number; yPct: number; widthPct: number;