From f294f67ced8b09e85efb1d061651cf02c35e6cfb Mon Sep 17 00:00:00 2001 From: fitse-yotor Date: Sun, 16 Aug 2026 22:21:00 +0300 Subject: [PATCH] fix(portal,backoffice): six reported issues in registration and the designer Seafarer and vessel registration were filtered out of the operations step by `requiresOperatorMode !== false`, so someone registering as a seafarer or vessel owner landed on an onboarding screen that did not describe them. Both now appear, grouped apart from the company modes: declaring "I am a seafarer" is a different kind of statement from "my company forwards freight". The designer's preview was gated on the Handlebars source alone, which a canvas layout does not have until the server compiles it on save -- so the button was dead for exactly the designs the canvas exists for. Editing looked broken rather than deliberately read-only: every seeded template is PUBLISHED, and a published design is immutable because certificates were issued from it. Says so, and offers the new-version action that is the way forward. Reviewing officers saw company, capital and staff tabs on seafarer certificate applications, because PRESENTATION is keyed by the generic licence keys and the fifty-odd rank-specific CoC/CoP keys fell through to the company default. Matched by prefix instead, so a certificate configured tomorrow gets the right presentation without a code change. Seaman book and BTC are wired to the newly seeded licence types and no longer marked "soon". Co-Authored-By: Claude Opus 5 --- .../components/BlockPropertiesPanel.tsx | 191 ++++++++++++ .../components/DesignerToolbar.tsx | 14 +- .../components/TemplateBackgroundPanel.tsx | 157 +++++++++- .../components/TemplateCanvas.tsx | 288 ++++++++++++++++++ .../components/TemplateVariableList.tsx | 37 ++- .../certificate-designer/config/designer.ts | 56 +++- .../config/layout-compiler.ts | 101 ++++++ .../hooks/useTemplateDraft.ts | 82 ++++- .../pages/CertificateDesignerPage.tsx | 160 +++++++++- .../license-review/config/license-types.ts | 43 ++- apps/backoffice/src/app/i18n/locales/am.ts | 1 + apps/backoffice/src/app/i18n/locales/en.ts | 1 + apps/backoffice/src/app/layouts/nav-config.ts | 3 +- .../licensing/components/LicenseCatalogue.tsx | 9 +- .../components/OperationsFormContent.tsx | 80 ++++- .../pages/SeamanBookApplicationPage.tsx | 19 +- .../seaman-book/pages/SeamanBookPage.tsx | 19 +- apps/portal/src/app/i18n/locales/am.ts | 2 + apps/portal/src/app/i18n/locales/en.ts | 2 + apps/portal/src/app/layouts/PortalLayout.tsx | 4 +- .../lib/features/licensing/licensing-api.ts | 5 + .../lib/features/licensing/licensing.types.ts | 49 ++- 22 files changed, 1237 insertions(+), 86 deletions(-) create mode 100644 apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx create mode 100644 apps/backoffice/src/app/features/certificate-designer/components/TemplateCanvas.tsx create mode 100644 apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts diff --git a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx new file mode 100644 index 000000000..8e8684350 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx @@ -0,0 +1,191 @@ +import { + ActionIcon, + ColorInput, + Group, + NumberInput, + Paper, + SegmentedControl, + Select, + Stack, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconTrash } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api'; + +interface Props { + block: TemplateFieldPlacement | null; + variables: TemplateVariable[]; + onChange: (block: TemplateFieldPlacement) => void; + onDelete: (id: string) => void; + disabled: boolean; +} + +/** Everything about the selected block that is not its position on the page. */ +export function BlockPropertiesPanel({ + block, + variables, + onChange, + onDelete, + disabled, +}: Props) { + const { t } = useTranslation(); + + if (!block) { + return ( + + + {t('designer.noBlockSelected', 'Select a block on the page to edit it.')} + + + ); + } + + const isLiteral = block.variable === null; + + return ( + + + + + {t('designer.blockProperties', 'Selected block')} + + + onDelete(block.id)} + > + + + + + + + onChange( + value === 'text' + ? { ...block, variable: null, text: block.text ?? '' } + : { ...block, variable: variables[0]?.key ?? 'companyName' }, + ) + } + data={[ + { value: 'variable', label: t('designer.blockVariable', 'Variable') }, + { value: 'text', label: t('designer.blockText', 'Fixed text') }, + ]} + /> + + {isLiteral ? ( + onChange({ ...block, text: e.currentTarget.value })} + disabled={disabled} + /> + ) : ( + ({ - value: type.id, - label: localized(type.name) || type.key, - }))} + data={groupedTypeOptions(licenseTypes, localized, t)} value={typeId} onChange={onTypeChange} - w={280} + searchable + nothingFoundMessage={t('designer.noTypeMatch', 'No licence type matches')} + maxDropdownHeight={340} + w={340} /> {/* Validity lives beside the design because it is the other half of 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 422814958..70e603189 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx @@ -1,23 +1,56 @@ -import { Button, Group, Image, Paper, Stack, Text, TextInput } from '@mantine/core'; +import { + Button, + Group, + Image, + NumberInput, + Paper, + SegmentedControl, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; import { IconPhotoUp, IconTrash } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; +import type { TemplateLogoCorner, TemplateLogoPlacement } from '@ema-platform/api'; interface Props { backgroundUrl: string; onBackgroundChange: (url: string) => void; + logoUrl: string; + onLogoChange: (url: string) => void; + logoPlacement: TemplateLogoPlacement; + onLogoPlacementChange: (placement: TemplateLogoPlacement) => void; + landscape: boolean; + onLandscapeChange: (landscape: boolean) => void; disabled: boolean; } +const CORNERS: { value: TemplateLogoCorner; labelKey: string; fallback: string }[] = [ + { value: 'TOP_LEFT', labelKey: 'designer.cornerTopLeft', fallback: 'Top left' }, + { value: 'TOP_CENTER', labelKey: 'designer.cornerTopCenter', fallback: 'Top centre' }, + { value: 'TOP_RIGHT', labelKey: 'designer.cornerTopRight', fallback: 'Top right' }, + { value: 'BOTTOM_LEFT', labelKey: 'designer.cornerBottomLeft', fallback: 'Bottom left' }, + { value: 'BOTTOM_RIGHT', labelKey: 'designer.cornerBottomRight', fallback: 'Bottom right' }, +]; + /** - * The artwork a certificate is printed on. + * Page-level settings: orientation, the artwork a certificate is printed on, + * and the authority's logo. * - * A background, not the certificate itself — the number, QR code and holder's - * name stay in the template layer above it, so one design serves every - * certificate it issues. + * Orientation belongs here rather than beside the version name because it is a + * property of the page, and it decides the shape of the canvas everything else + * is positioned on. */ export function TemplateBackgroundPanel({ backgroundUrl, onBackgroundChange, + logoUrl, + onLogoChange, + logoPlacement, + onLogoPlacementChange, + landscape, + onLandscapeChange, disabled, }: Props) { const { t } = useTranslation(); @@ -27,16 +60,44 @@ export function TemplateBackgroundPanel({
- {t('designer.background', 'Background artwork')} + {t('designer.pageSetup', 'Page setup')} {t( - 'designer.backgroundHint', - 'Printed underneath the template. Certificate data is drawn on top, so the same artwork serves every certificate.', + 'designer.pageSetupHint', + 'Orientation, background artwork and the authority logo. Certificate data is drawn on top, so the same page serves every certificate.', )}
+ +
+ + {t('designer.orientation', 'Orientation')} + + onLandscapeChange(value === 'landscape')} + disabled={disabled} + data={[ + { + value: 'portrait', + label: t('designer.portrait', 'Portrait'), + }, + { + value: 'landscape', + label: t('designer.landscape', 'Landscape'), + }, + ]} + /> + + {landscape + ? t('designer.a4Landscape', 'A4 — 297 × 210 mm') + : t('designer.a4Portrait', 'A4 — 210 × 297 mm')} + +
+
+
)} + + + onLogoChange(e.currentTarget.value)} + disabled={disabled} + style={{ flex: 1 }} + /> + {logoUrl && ( + + )} + + + {logoUrl && ( + +