From 09a6f492def3d64e84e879088f693dead6990459 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 1 Sep 2026 07:33:00 +0000 Subject: [PATCH 1/4] refactor(exam): drop the last trace of "Eligible to Register" The label was reverted to "Exam Paid" but the name survived as the internal stage key, so the concept was still in the codebase to be reintroduced by accident. The stage is named after the status it derives from now. --- .../features/certificates/pages/CertificatesPage.tsx | 2 +- .../src/app/features/licensing/exam-stage.spec.ts | 4 ++-- apps/portal/src/app/features/licensing/exam-stage.ts | 10 ++++++---- apps/portal/src/app/i18n/locales/am.ts | 2 +- apps/portal/src/app/i18n/locales/en.ts | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index 93814873e..360f5ef2d 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -114,7 +114,7 @@ const STATUS_COLOR: Record = { * keys because this page states its statuses the same way. */ const EXAM_STAGE_LABELS: Record = { - ELIGIBLE_TO_REGISTER: 'Eligible — Register for a Sitting', + EXAM_PAID: 'Exam Paid', REGISTERED: 'Exam Scheduled', ATTENDANCE_CONFIRMED: 'Exam Attendance Confirmed', SITTING: 'Exam In Progress', diff --git a/apps/portal/src/app/features/licensing/exam-stage.spec.ts b/apps/portal/src/app/features/licensing/exam-stage.spec.ts index 22ec0deca..2003537e9 100644 --- a/apps/portal/src/app/features/licensing/exam-stage.spec.ts +++ b/apps/portal/src/app/features/licensing/exam-stage.spec.ts @@ -29,8 +29,8 @@ describe('examStageFor', () => { expect(examStageFor({ status: 'CERTIFICATE_ISSUED' })).toBeNull(); }); - it('reads a paid application as eligible to register, not as waiting', () => { - expect(examStageFor({ status: 'EXAM_PAID' })).toBe('ELIGIBLE_TO_REGISTER'); + it('reads a paid application as Exam Paid, not as awaiting a date', () => { + expect(examStageFor({ status: 'EXAM_PAID' })).toBe('EXAM_PAID'); }); it('reports a registration whose attendance has not been taken', () => { diff --git a/apps/portal/src/app/features/licensing/exam-stage.ts b/apps/portal/src/app/features/licensing/exam-stage.ts index c7aaaaaec..1697f7c4b 100644 --- a/apps/portal/src/app/features/licensing/exam-stage.ts +++ b/apps/portal/src/app/features/licensing/exam-stage.ts @@ -16,7 +16,7 @@ import type { MyRegistration } from '../exams/pages/ExamsPage'; * already recorded — this only names what the records add up to. */ export type ExamStage = - | 'ELIGIBLE_TO_REGISTER' + | 'EXAM_PAID' | 'REGISTERED' | 'ATTENDANCE_CONFIRMED' | 'SITTING' @@ -41,9 +41,11 @@ export function examStageFor( if (app.status === 'EXAM_PASSED') return 'PASSED'; if (app.status === 'EXAM_FAILED') return 'FAILED'; - // EXAM_PAID is "prerequisites done, sitting not yet chosen": the fee has - // cleared and nothing is left but for the candidate to pick a session. - if (app.status === 'EXAM_PAID') return 'ELIGIBLE_TO_REGISTER'; + // The fee has cleared and nothing is left but for the candidate to pick a + // session. Named after the status rather than after what the candidate + // should do next: "Eligible to Register" read as a status of its own and + // said less than the plain one. + if (app.status === 'EXAM_PAID') return 'EXAM_PAID'; if (app.status !== 'EXAM_SCHEDULED') return null; diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index be22f0b46..db485310f 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -244,7 +244,7 @@ export const am: Translations = { empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።', }, examStage: { - ELIGIBLE_TO_REGISTER: "ብቁ ነዎት — ለፈተና ይመዝገቡ", + EXAM_PAID: "የፈተና ክፍያ ተከፍሏል", REGISTERED: "የፈተና ቀን፦ {{date}}", ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል", SITTING: "ፈተና በመካሄድ ላይ", diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index ac1925ca9..ce8de654f 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -249,7 +249,7 @@ export const en = { * from the application status alone — see exam-stage.ts. */ examStage: { - ELIGIBLE_TO_REGISTER: 'Eligible — register for a sitting', + EXAM_PAID: 'Exam Paid', REGISTERED: 'Exam scheduled: {{date}}', ATTENDANCE_CONFIRMED: 'Attendance confirmed', SITTING: 'Exam in progress', From 6e32d555e63af7f0b6606cb33c4229bd297f98a6 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 1 Sep 2026 07:34:32 +0000 Subject: [PATCH 2/4] fix(exam): show EXAM_PAID as "Exam Paid" in the queue label map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revert of this label was lost when the branch was reset, so the back office kept rendering "Eligible to Register" — a status name nobody recognised, saying less than the plain one it replaced. The status names the fact the application records; the Register button carries the action. --- libs/api/src/lib/features/licensing/licensing.helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index c2396408c..4b39320d1 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -88,7 +88,7 @@ export const STATUS_LABELS: Record = { EXAM_PAYMENT_PENDING: 'Exam Fee Due', // Nobody assigns a date any more — the fee clearing is what makes the // candidate eligible to register for a published sitting themselves. - EXAM_PAID: 'Eligible to Register', + EXAM_PAID: 'Exam Paid', EXAM_SCHEDULED: 'Exam Scheduled', EXAM_PASSED: 'Exam Passed', EXAM_FAILED: 'Exam Not Passed', From 64836d6e1ed280cdd651522197485a2cf3da549f Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 1 Sep 2026 15:18:13 +0300 Subject: [PATCH 3/4] fix: sanitize applicationId and documentId parameters in PaymentCheckPage --- .../src/app/features/payments/pages/PaymentCheckPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/app/features/payments/pages/PaymentCheckPage.tsx b/apps/portal/src/app/features/payments/pages/PaymentCheckPage.tsx index e4f302255..fd6d2c7a1 100644 --- a/apps/portal/src/app/features/payments/pages/PaymentCheckPage.tsx +++ b/apps/portal/src/app/features/payments/pages/PaymentCheckPage.tsx @@ -34,9 +34,9 @@ export function PaymentCheckPage() { const { t } = useTranslation(); const [params] = useSearchParams(); const navigate = useNavigate(); - const applicationId = params.get('applicationId') ?? ''; + const applicationId = (params.get('applicationId') ?? '').split('?')[0]; // Seaman Book / BTC fees come back with `documentId` instead. - const documentId = params.get('documentId') ?? ''; + const documentId = (params.get('documentId') ?? '').split('?')[0]; const [attempts, setAttempts] = useState(0); const applicationPayment = useGetApplicationPaymentQuery(applicationId, { From 984dec0d6d422bc1318a47373cd73d939e80806a Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 2 Sep 2026 10:43:26 +0000 Subject: [PATCH 4/4] feat: add multi-page support, improve template creation logic, and refine rank resolution in certificate designer --- .../components/BlockPropertiesPanel.tsx | 15 +++ .../components/DesignerToolbar.tsx | 5 +- .../components/TemplateCanvas.tsx | 42 +++++++- .../components/TemplateEditor.tsx | 2 +- .../config/layout-compiler.ts | 40 ++++++- .../hooks/useTemplateDraft.ts | 27 ++++- .../pages/CertificateDesignerPage.tsx | 101 ++++++++++++------ apps/backoffice/src/app/i18n/locales/am.ts | 9 +- apps/backoffice/src/app/i18n/locales/en.ts | 9 +- .../lib/features/licensing/licensing-api.ts | 16 ++- .../lib/features/licensing/licensing.types.ts | 6 ++ 11 files changed, 224 insertions(+), 48 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx index 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;