Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-09-08 12:01:43 +03:00
11 changed files with 1242 additions and 7 deletions

View File

@@ -0,0 +1,94 @@
import { Alert, Button, Code, List, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconAlertTriangle, IconCircleCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { ImportResult } from '../config/html-import';
interface Props {
opened: boolean;
onClose: () => void;
result: ImportResult | null;
/** Replaces the draft's blocks/background with the imported ones. */
onApply: () => void;
}
/**
* The result of "Import to canvas" — how many blocks landed, and an itemized
* list of what could not be placed and why.
*
* Shown before the import is applied (not after) so a design that resolves
* to nothing (a flex/table-heavy hand-written template — see
* `html-import.ts`'s own doc comment) is not silently swapped in as an empty
* canvas; the author decides whether the partial result is still worth
* having.
*/
export function ImportHtmlModal({ opened, onClose, result, onApply }: Props) {
const { t } = useTranslation();
if (!result) return null;
const { placements, unmapped, backgroundUrl } = result;
return (
<Modal opened={opened} onClose={onClose} title={t('designer.importTitle', 'Import to canvas')} size="lg">
<Stack gap="md">
<Alert
variant="light"
color={placements.length > 0 ? 'teal' : 'yellow'}
icon={placements.length > 0 ? <IconCircleCheck size={18} /> : <IconAlertTriangle size={18} />}
>
<Text size="sm">
{placements.length > 0
? t(
'designer.importSummary',
'{{count}} element(s) placed on the canvas{{bg}}.',
{
count: placements.length,
bg: backgroundUrl ? t('designer.importAndBackground', ', plus the background image') : '',
},
)
: t(
'designer.importNothingPlaced',
'Nothing could be placed on the canvas. This design uses layout the canvas cannot represent — see below.',
)}
</Text>
</Alert>
{unmapped.length > 0 && (
<Stack gap={4}>
<Text size="sm" fw={600}>
{t('designer.importUnmappedTitle', '{{count}} element(s) need manual placement:', {
count: unmapped.length,
})}
</Text>
<List size="sm" spacing={4} icon={
<ThemeIcon color="yellow" size={16} radius="xl" variant="light">
<IconAlertTriangle size={10} />
</ThemeIcon>
}>
{unmapped.map((w, i) => (
<List.Item key={i}>
<Code fz={11}>{w.element}</Code> {w.reason}
</List.Item>
))}
</List>
</Stack>
)}
<Text size="xs" c="dimmed">
{t(
'designer.importOverwriteWarning',
'Applying replaces every block currently on the canvas for this version.',
)}
</Text>
<Button.Group>
<Button variant="default" onClick={onClose} style={{ flex: 1 }}>
{t('common.cancel', 'Cancel')}
</Button>
<Button onClick={onApply} disabled={placements.length === 0 && !backgroundUrl} style={{ flex: 1 }}>
{t('designer.importApply', 'Apply to canvas')}
</Button>
</Button.Group>
</Stack>
</Modal>
);
}

View File

@@ -1,5 +1,6 @@
import type { RefObject } from 'react';
import { Group, Paper, Switch, Text, TextInput, Textarea } from '@mantine/core';
import { Button, Group, Paper, Switch, Text, TextInput, Textarea, Tooltip } from '@mantine/core';
import { IconFileImport } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface Props {
@@ -12,6 +13,8 @@ interface Props {
editorRef: RefObject<HTMLTextAreaElement | null>;
disabled: boolean;
isPublished: boolean;
/** Parses `source` and opens the import report — undefined hides the button entirely. */
onImportToCanvas?: () => void;
}
/** Name, orientation and the Handlebars source for the selected version. */
@@ -25,6 +28,7 @@ export function TemplateEditor({
editorRef,
disabled,
isPublished,
onImportToCanvas,
}: Props) {
const { t } = useTranslation();
@@ -44,6 +48,24 @@ export function TemplateEditor({
onChange={(e) => onLandscapeChange(e.currentTarget.checked)}
disabled={disabled}
/>
{onImportToCanvas && (
<Tooltip
label={t(
'designer.importHint',
'Reads this HTML and places whatever it can as canvas blocks — a report lists what could not be placed.',
)}
>
<Button
variant="default"
size="sm"
leftSection={<IconFileImport size={15} />}
disabled={disabled || !source.trim()}
onClick={onImportToCanvas}
>
{t('designer.importToCanvas', 'Import to canvas')}
</Button>
</Tooltip>
)}
</Group>
{isPublished && (

View File

@@ -0,0 +1,435 @@
import { describe, expect, it } from 'vitest';
import { htmlToPlacements, parseInlineStyle, type ImportedElement } from './html-import';
import { compileLayoutToHbs } from './layout-compiler';
import type { TemplateFieldPlacement } from '@ema-platform/api';
/** Builds a fixture element without having to repeat every empty field. */
function el(
tag: string,
opts: Partial<Pick<ImportedElement, 'style' | 'attrs' | 'text' | 'children'>> = {},
): ImportedElement {
return {
tag,
style: opts.style ?? {},
attrs: opts.attrs ?? {},
text: opts.text ?? '',
children: opts.children ?? [],
};
}
/** style="..." attribute value → the parsed-declaration object the walker expects. */
function styled(css: string): Record<string, string> {
return parseInlineStyle(css);
}
describe('parseInlineStyle', () => {
it('splits declarations and lowercases property names', () => {
expect(parseInlineStyle('Position: absolute; Left:10%; width : 30% ')).toEqual({
position: 'absolute',
left: '10%',
width: '30%',
});
});
it('ignores malformed declarations and empty input', () => {
expect(parseInlineStyle('not-a-declaration')).toEqual({});
expect(parseInlineStyle(undefined)).toEqual({});
expect(parseInlineStyle(null)).toEqual({});
});
});
describe('htmlToPlacements — text blocks', () => {
it('places an absolutely-positioned literal text leaf', () => {
const root = el('body', {
children: [
el('div', { style: styled('position:absolute;left:10%;top:20%;width:30%;'), text: 'Hello' }),
],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(unmapped).toEqual([]);
expect(placements).toHaveLength(1);
expect(placements[0]).toMatchObject({
variable: null,
text: 'Hello',
type: 'text',
xPct: 10,
yPct: 20,
widthPct: 30,
});
});
it('recognises a {{variable}} leaf as a variable block, not literal text', () => {
const root = el('body', {
children: [
el('div', { style: styled('position:absolute;left:0%;top:0%;width:40%;'), text: '{{holderName}}' }),
],
});
const { placements } = htmlToPlacements(root);
expect(placements[0]).toMatchObject({ variable: 'holderName', text: undefined });
});
it('reads font styling off the element', () => {
const root = el('body', {
children: [
el('div', {
style: styled(
'position:absolute;left:5%;top:5%;width:50%;font-size:18px;font-weight:bold;font-style:italic;text-align:center;color:#0b3d6b;',
),
text: 'Styled',
}),
],
});
const { placements } = htmlToPlacements(root);
expect(placements[0]).toMatchObject({
fontSize: 18,
fontWeight: 'bold',
fontStyle: 'italic',
align: 'center',
color: '#0b3d6b',
});
});
it('converts pt font sizes and numeric font-weight to the canvas convention', () => {
const root = el('body', {
children: [
el('div', {
style: styled('position:absolute;left:0%;top:0%;width:10%;font-size:12pt;font-weight:700;'),
text: 'x',
}),
],
});
const { placements } = htmlToPlacements(root);
expect(placements[0].fontSize).toBe(16); // 12pt * 96/72
expect(placements[0].fontWeight).toBe('bold');
});
});
describe('htmlToPlacements — images', () => {
it('places an absolutely-positioned <img> as an image block', () => {
const root = el('body', {
children: [
el('img', {
style: styled('position:absolute;left:70%;top:5%;width:20%;'),
attrs: { src: '{{{sealImage}}}' },
}),
],
});
const { placements } = htmlToPlacements(root);
expect(placements[0]).toMatchObject({
type: 'image',
variable: 'sealImage',
xPct: 70,
yPct: 5,
widthPct: 20,
});
});
it('treats a full-bleed <img> as the background rather than a block', () => {
const root = el('body', {
children: [
el('img', {
style: styled('position:absolute;inset:0;width:100%;height:100%;'),
attrs: { src: 'https://example.org/bg.png' },
}),
el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'Hi' }),
],
});
const { placements, backgroundUrl } = htmlToPlacements(root);
expect(backgroundUrl).toBe('https://example.org/bg.png');
expect(placements).toHaveLength(1); // only the text block, not the background image
});
it('recognises class="ema-background" with no inline position at all', () => {
// Exactly what compileLayoutToHbs itself emits: the image carries no
// inline style — position:absolute;inset:0 lives only in the compiled
// document's <style> block, under the .ema-background selector.
const root = el('body', {
children: [
el('img', { attrs: { class: 'ema-background', src: 'https://example.org/bg.png' } }),
el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'Hi' }),
],
});
const { placements, backgroundUrl, unmapped } = htmlToPlacements(root);
expect(backgroundUrl).toBe('https://example.org/bg.png');
expect(placements).toHaveLength(1);
expect(unmapped).toEqual([]);
});
it('extracts the mustache variable out of a background image src', () => {
const root = el('body', {
children: [
el('img', {
style: styled('position:absolute;left:0;top:0;width:100%;height:100%;'),
attrs: { src: '{{{backgroundUrl}}}' },
}),
],
});
const { backgroundUrl } = htmlToPlacements(root);
expect(backgroundUrl).toBe('backgroundUrl');
});
it('reports a non-positioned image as unmapped rather than guessing a spot', () => {
const root = el('body', {
children: [el('img', { attrs: { src: 'logo.png' } })],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toEqual([]);
expect(unmapped).toHaveLength(1);
expect(unmapped[0].reason).toMatch(/not absolutely positioned/);
});
});
describe('htmlToPlacements — a positioned wrapper around one child', () => {
it('imports a single meaningful child, inheriting the wrapper box', () => {
const root = el('body', {
children: [
el('div', {
style: styled('position:absolute;left:15%;top:15%;width:25%;'),
children: [el('span', { text: '{{approverName}}' })],
}),
],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(unmapped).toEqual([]);
expect(placements).toHaveLength(1);
expect(placements[0]).toMatchObject({ variable: 'approverName', xPct: 15, yPct: 15, widthPct: 25 });
});
it('reports a positioned wrapper with multiple children as unmapped', () => {
const root = el('body', {
children: [
el('div', {
style: styled('position:absolute;left:0%;top:0%;width:50%;'),
children: [el('span', { text: 'A' }), el('span', { text: 'B' })],
}),
],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toEqual([]);
expect(unmapped).toHaveLength(1);
expect(unmapped[0].reason).toMatch(/more than one piece of content/);
});
});
describe('htmlToPlacements — things it deliberately does not import', () => {
it('flags a table', () => {
const root = el('body', { children: [el('table', { attrs: { class: 'facts' } })] });
const { unmapped } = htmlToPlacements(root);
expect(unmapped).toHaveLength(1);
expect(unmapped[0].element).toBe('<table class="facts">');
expect(unmapped[0].reason).toMatch(/Tables/);
});
it('flags a flex container', () => {
const root = el('body', { children: [el('div', { style: styled('display:flex;') })] });
const { unmapped } = htmlToPlacements(root);
expect(unmapped[0].reason).toMatch(/Flex\/grid/);
});
it('flags a grid container', () => {
const root = el('body', { children: [el('div', { style: styled('display:grid;') })] });
expect(htmlToPlacements(root).unmapped[0].reason).toMatch(/Flex\/grid/);
});
it('flags a block helper ({{#if}}) rather than treating it as literal text', () => {
const root = el('body', {
children: [el('div', { text: '{{#if rankLimitation}}{{rankLimitation}}{{/if}}' })],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toEqual([]);
expect(unmapped[0].reason).toMatch(/conditional\/loop/);
});
it('does not flag a plain structural wrapper with no content of its own', () => {
const root = el('body', {
children: [
el('div', {
attrs: { class: 'wrapper' },
children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'x' })],
}),
],
});
const { unmapped } = htmlToPlacements(root);
expect(unmapped).toEqual([]);
});
it('imports only the first .ema-page and flags the rest', () => {
const root = el('body', {
children: [
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p1' })] }),
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p2' })] }),
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p3' })] }),
],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toHaveLength(1);
expect(placements[0].text).toBe('p1');
// One warning per later page, not one for the whole rest of the document.
expect(unmapped).toHaveLength(2);
expect(unmapped.every((w) => w.reason.match(/later page/))).toBe(true);
});
it('imports everything when the source has no .ema-page wrapper at all', () => {
const root = el('body', {
children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'plain' })],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toHaveLength(1);
expect(unmapped).toEqual([]);
});
});
describe('htmlToPlacements — unit conversion', () => {
it('converts mm against an explicit page size', () => {
const root = el('body', {
children: [el('div', { style: styled('position:absolute;left:21mm;top:29.7mm;width:105mm;'), text: 'x' })],
});
const { placements } = htmlToPlacements(root, { pageWidthMm: 210, pageHeightMm: 297 });
expect(placements[0].xPct).toBeCloseTo(10, 5);
expect(placements[0].yPct).toBeCloseTo(10, 5);
expect(placements[0].widthPct).toBeCloseTo(50, 5);
});
it('resolves width from right when width is absent', () => {
const root = el('body', {
children: [el('div', { style: styled('position:absolute;left:10%;top:0%;right:20%;'), text: 'x' })],
});
const { placements } = htmlToPlacements(root);
expect(placements[0].widthPct).toBe(70); // 100 - 10 - 20
});
it('treats an unresolvable length (calc/auto) as unmapped, not a guess', () => {
const root = el('body', {
children: [el('div', { style: styled('position:absolute;left:calc(50% - 10px);top:0%;width:10%;'), text: 'x' })],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toEqual([]);
expect(unmapped[0].reason).toMatch(/plain length/);
});
});
describe('round trip — importing EMA\'s own compiled canvas output', () => {
it('reproduces the same placements a canvas design was compiled from', () => {
const original: TemplateFieldPlacement[] = [
{ id: 'a', variable: 'holderName', xPct: 10, yPct: 10, widthPct: 40, fontSize: 16, fontWeight: 'bold', fontStyle: 'normal', align: 'left', color: '#111111' },
{ id: 'b', variable: null, text: 'Certificate of Competency', xPct: 10, yPct: 30, widthPct: 60, fontSize: 20, fontWeight: 'bold', fontStyle: 'normal', align: 'center', color: '#0b3d6b' },
{ id: 'c', variable: 'sealImage', type: 'image', xPct: 70, yPct: 80, widthPct: 15 },
];
const hbs = compileLayoutToHbs({
backgroundUrl: 'https://assets.example.org/bg.png',
logoUrl: '',
logoPlacement: {},
fieldPlacements: original,
});
const root = parseFragment(hbs);
const { placements, backgroundUrl, unmapped } = htmlToPlacements(root);
expect(unmapped).toEqual([]);
expect(backgroundUrl).toBe('https://assets.example.org/bg.png');
expect(placements).toHaveLength(3);
const byVarOrText = (p: TemplateFieldPlacement) => p.variable ?? p.text;
for (const orig of original) {
const match = placements.find((p) => byVarOrText(p) === byVarOrText(orig));
expect(match, `expected a re-imported block for ${byVarOrText(orig)}`).toBeTruthy();
expect(match?.xPct).toBeCloseTo(orig.xPct, 5);
expect(match?.yPct).toBeCloseTo(orig.yPct, 5);
expect(match?.widthPct).toBeCloseTo(orig.widthPct, 5);
if (orig.type === 'image') {
expect(match?.type).toBe('image');
} else {
expect(match?.fontSize).toBe(orig.fontSize);
expect(match?.fontWeight).toBe(orig.fontWeight);
expect(match?.align).toBe(orig.align);
expect(match?.color).toBe(orig.color);
}
}
});
});
/**
* A tiny, dependency-free HTML fragment parser — test-only. This app's test
* environment is plain Node with no DOM implementation (see vite.config.mts:
* "no jsdom environment... is needed"), so the round-trip test above cannot
* use a real `DOMParser`. It only ever needs to parse `compileLayoutToHbs`'s
* own well-formed output (self-closing `<img/>`, no comments, `<style>`
* treated as opaque text), not arbitrary HTML — the production import path
* (`importHtmlSource`) uses the browser's real `DOMParser` instead.
*/
function parseFragment(html: string): ImportedElement {
const VOID_TAGS = new Set(['img', 'br', 'hr', 'meta', 'link', 'input']);
const OPAQUE_TAGS = new Set(['style', 'script']);
let i = 0;
function parseAttrs(tagSrc: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re = /([a-zA-Z-]+)\s*=\s*"([^"]*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(tagSrc))) attrs[m[1]] = m[2];
return attrs;
}
/**
* Reads every node up to (and consuming) `</untilTag>`, or end of input for
* the synthetic document root (`untilTag === null`). Text runs become the
* *current* element's own text (the caller passes its own tag as
* `untilTag`); a nested element's opening tag starts a fresh recursive call
* so its own text is captured the same way, one level down.
*/
function parseUntil(untilTag: string | null): { text: string; children: ImportedElement[] } {
const children: ImportedElement[] = [];
let text = '';
while (i < html.length) {
if (html[i] !== '<') {
text += html[i];
i++;
continue;
}
if (html.startsWith('<!', i)) {
// Comment or doctype — skip to '>'.
i = html.indexOf('>', i) + 1;
continue;
}
if (html.startsWith('</', i)) {
const end = html.indexOf('>', i);
const closingTag = html.slice(i + 2, end).trim().toLowerCase();
i = end + 1;
if (closingTag === untilTag) return { text, children };
continue; // mismatched close (shouldn't happen in well-formed input) — ignore
}
const end = html.indexOf('>', i);
const tagSrc = html.slice(i + 1, end);
const selfClosing = tagSrc.trim().endsWith('/');
const tag = (tagSrc.match(/^([a-zA-Z0-9]+)/)?.[1] ?? '').toLowerCase();
const attrs = parseAttrs(tagSrc);
i = end + 1;
if (OPAQUE_TAGS.has(tag)) {
const close = `</${tag}>`;
const closeAt = html.indexOf(close, i);
i = closeAt === -1 ? html.length : closeAt + close.length;
continue;
}
if (selfClosing || VOID_TAGS.has(tag)) {
children.push({ tag, style: parseInlineStyle(attrs.style), attrs, text: '', children: [] });
continue;
}
const nested = parseUntil(tag);
children.push({ tag, style: parseInlineStyle(attrs.style), attrs, text: nested.text, children: nested.children });
}
return { text, children };
}
// `compileLayoutToHbs` always emits a full `<!doctype html><html>...`
// document — wrap the parsed top-level nodes in a synthetic root so the
// caller gets one element back, same shape `document.documentElement`
// would have been.
const { children } = parseUntil(null);
return { tag: 'root', style: {}, attrs: {}, text: '', children };
}

View File

@@ -0,0 +1,565 @@
import type { TemplateFieldPlacement } from '@ema-platform/api';
/**
* Imports a hand-authored Handlebars/HTML certificate into canvas blocks.
*
* This is necessarily best-effort. The canvas's own model (`TemplateFieldPlacement`)
* is a flat list of absolutely-positioned boxes — see `layout-compiler.ts`, the
* only thing that has ever produced one. Arbitrary HTML has no such
* constraint: a `<table>`, a flex/grid layout, a `{{#if}}` branch, or a
* relatively-flowed paragraph has no single `left/top/width` that means the
* same thing once lifted out of its surrounding markup. Rather than silently
* dropping those, every element the importer cannot place as a clean box is
* reported back so the author knows to finish it by hand (in the HTML source
* tab, or by rebuilding it as a block on the canvas).
*
* What DOES import cleanly: exactly the shape `compileLayoutToHbs` itself
* emits — an element with inline `position:absolute; left; top; width` (in
* %, mm, in, or px, resolved against the declared page size) carrying either
* a text/`{{variable}}` node or an `<img>`. Every bundled certificate design
* in this codebase is authored that way for exactly this reason — importing
* one and re-editing it on the canvas is a fully lossless round trip.
*/
// ---------------------------------------------------------------------------
// A minimal, DOM-library-agnostic element shape.
//
// The real adapter (`domElementToImported`, at the bottom) wraps a browser
// `Element`; tests build this shape directly, so the extraction logic itself
// needs no DOM implementation and runs the same in Node as in the browser.
// ---------------------------------------------------------------------------
export interface ImportedElement {
/** Lowercase tag name, e.g. "div", "img". */
tag: string;
/** Inline `style="..."` attribute, already split into declarations. */
style: Record<string, string>;
attrs: Record<string, string>;
/**
* This element's own text, excluding any child elements' text — i.e. what
* a DOM adapter would build from the element's direct Text node children
* only, not `element.textContent` (which would also pull in every
* descendant's text and double-count it once children are walked too).
*/
text: string;
children: ImportedElement[];
}
export interface ImportWarning {
/** Where the problem is, for the report: a short tag/class/id descriptor. */
element: string;
reason: string;
}
export interface ImportResult {
placements: TemplateFieldPlacement[];
/** First plain `<img>` covering (close to) the whole page — see `isBackgroundImage`. */
backgroundUrl: string | null;
unmapped: ImportWarning[];
}
export interface ImportOptions {
/** The page box these percentages are resolved against. Defaults to A4 portrait mm. */
pageWidthMm?: number;
pageHeightMm?: number;
}
const DEFAULT_PAGE_WIDTH_MM = 210;
const DEFAULT_PAGE_HEIGHT_MM = 297;
/** Structural tags that never become a block themselves — only their content does. */
const CONTAINER_TAGS = new Set(['html', 'body', 'head', 'style', 'script']);
/** Constructs a stable-enough id; collisions are harmless (React key + selection only). */
function blockId(index: number): string {
return `import_${index}_${Math.random().toString(36).slice(2, 8)}`;
}
// ---------------------------------------------------------------------------
// Unit conversion — every length the importer accepts, resolved to a percent
// of the declared page box. `%` values pass through unchanged (already what
// the canvas stores); everything else is a physical or pixel length assumed
// at 96 CSS px per inch, the same assumption a browser makes.
// ---------------------------------------------------------------------------
const PX_PER_IN = 96;
const MM_PER_IN = 25.4;
function toMm(value: number, unit: string): number | null {
switch (unit) {
case 'mm':
return value;
case 'cm':
return value * 10;
case 'in':
return value * MM_PER_IN;
case 'px':
return (value / PX_PER_IN) * MM_PER_IN;
case 'pt':
return ((value / 72) * MM_PER_IN);
default:
return null;
}
}
/**
* Resolves a CSS length against one page dimension, as a percent of it.
* `null` for anything not expressible as a plain length (a %, unitless
* number, `calc()`, `auto`, a CSS variable) — the caller decides what that
* means for the element carrying it.
*/
function lengthToPct(raw: string | undefined, pageDimensionMm: number): number | null {
if (!raw) return null;
const trimmed = raw.trim();
const pctMatch = trimmed.match(/^(-?[\d.]+)%$/);
if (pctMatch) return Number(pctMatch[1]);
const lengthMatch = trimmed.match(/^(-?[\d.]+)(mm|cm|in|px|pt)$/);
if (!lengthMatch) return null;
const mm = toMm(Number(lengthMatch[1]), lengthMatch[2]);
if (mm === null || pageDimensionMm <= 0) return null;
return (mm / pageDimensionMm) * 100;
}
// ---------------------------------------------------------------------------
// Inline style parsing — a plain `key:value;key:value` splitter. No CSS
// library needed: this only ever sees a `style="..."` attribute value, never
// a full stylesheet (selectors, media queries, `<style>` blocks are out of
// scope for the same reason a `<table>` is — see the module doc).
// ---------------------------------------------------------------------------
export function parseInlineStyle(styleAttr: string | null | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!styleAttr) return out;
for (const decl of styleAttr.split(';')) {
const colon = decl.indexOf(':');
if (colon === -1) continue;
const key = decl.slice(0, colon).trim().toLowerCase();
const value = decl.slice(colon + 1).trim();
if (key && value) out[key] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// Mustache extraction from a text node — `{{{var}}}` (unescaped, the image
// convention) and `{{var}}` (escaped text/variable). A block helper opening
// or closing tag (`{{#if x}}`, `{{/if}}`, `{{else}}`) is deliberately not
// matched here: those don't name a single value to place, so they surface as
// literal, unmapped text instead of being silently swallowed.
// ---------------------------------------------------------------------------
const TRIPLE_MUSTACHE = /^\{\{\{\s*([\w.]+)\s*\}\}\}$/;
const DOUBLE_MUSTACHE = /^\{\{\s*([\w.]+)\s*\}\}$/;
const BLOCK_HELPER = /\{\{[#/]|\{\{\s*else\b/;
function extractVariable(text: string): { variable: string; raw: boolean } | null {
const trimmed = text.trim();
const triple = trimmed.match(TRIPLE_MUSTACHE);
if (triple) return { variable: triple[1], raw: true };
const double = trimmed.match(DOUBLE_MUSTACHE);
if (double) return { variable: double[1], raw: false };
return null;
}
// ---------------------------------------------------------------------------
// Geometry — is this element itself "absolutely placeable" as one box?
// ---------------------------------------------------------------------------
interface Box {
xPct: number;
yPct: number;
widthPct: number;
}
/**
* The box an element occupies, or null when it isn't expressed as a length
* the importer understands (a % or physical unit) against `left`/`top` and
* either `width` or `right`. `position` is checked by the caller — this only
* resolves the numbers once that gate has already passed.
*/
function boxFor(style: Record<string, string>, pageWidthMm: number, pageHeightMm: number): Box | null {
const left = lengthToPct(style.left, pageWidthMm);
const top = lengthToPct(style.top, pageHeightMm);
if (left === null || top === null) return null;
let width = lengthToPct(style.width, pageWidthMm);
if (width === null && style.right) {
const right = lengthToPct(style.right, pageWidthMm);
if (right !== null) width = 100 - left - right;
}
if (width === null) return null;
return { xPct: left, yPct: top, widthPct: width };
}
/** `font-size` resolved to px — the unit the canvas itself stores. */
function fontSizePx(style: Record<string, string>): number | undefined {
const raw = style['font-size'];
if (!raw) return undefined;
const match = raw.trim().match(/^([\d.]+)(px|pt)?$/);
if (!match) return undefined;
const n = Number(match[1]);
if (!Number.isFinite(n)) return undefined;
return match[2] === 'pt' ? Math.round(n * (96 / 72)) : Math.round(n);
}
function fontWeight(style: Record<string, string>): 'normal' | 'bold' | undefined {
const raw = style['font-weight']?.trim().toLowerCase();
if (!raw) return undefined;
if (raw === 'bold' || raw === 'bolder') return 'bold';
const n = Number(raw);
if (Number.isFinite(n)) return n >= 600 ? 'bold' : 'normal';
return 'normal';
}
function fontStyle(style: Record<string, string>): 'normal' | 'italic' | undefined {
const raw = style['font-style']?.trim().toLowerCase();
return raw === 'italic' || raw === 'oblique' ? 'italic' : undefined;
}
function textAlign(style: Record<string, string>): TemplateFieldPlacement['align'] | undefined {
const raw = style['text-align']?.trim().toLowerCase();
if (raw === 'left' || raw === 'center' || raw === 'right' || raw === 'justify') return raw;
return undefined;
}
/** A short, human-readable label for a warning — tag, id, first class, in that order of usefulness. */
function describe(el: ImportedElement): string {
if (el.attrs.id) return `<${el.tag} id="${el.attrs.id}">`;
const cls = el.attrs.class?.trim().split(/\s+/)[0];
if (cls) return `<${el.tag} class="${cls}">`;
return `<${el.tag}>`;
}
/**
* A plain `<img>` is treated as the page background — rather than a block —
* when it covers essentially the whole page: `inset:0` / `position:absolute`
* with no meaningful left/top/width of its own, or explicit 100%-ish
* width+height. This is the same role `compileLayoutToHbs` gives
* `.ema-background`, so importing one of EMA's own compiled designs finds it.
*
* `compileLayoutToHbs` itself gives that image no inline style at all — its
* `position:absolute; inset:0` comes entirely from the `.ema-background`
* class in the document's `<style>` block, which this importer does not
* (and, per the module doc, deliberately does not) resolve selectors
* against. `class="ema-background"` is checked first and directly for
* exactly that reason: it is the one CSS-class signal worth trusting, since
* it is the compiler's own marker for this element's role, not a generic
* stylesheet rule an importer would have to interpret.
*/
function isBackgroundImage(attrs: Record<string, string>, style: Record<string, string>): boolean {
if (attrs.class?.trim().split(/\s+/).includes('ema-background')) return true;
const position = style.position?.trim().toLowerCase();
if (position !== 'absolute' && position !== 'fixed') return false;
const inset = style.inset?.trim().replace(/\s+/g, ' ');
if (inset === '0' || inset === '0 0 0 0' || inset === '0px 0px 0px 0px') return true;
const width = style.width?.trim();
const height = style.height?.trim();
const noOffset = (!style.left || style.left.trim() === '0' || style.left.trim() === '0px')
&& (!style.top || style.top.trim() === '0' || style.top.trim() === '0px');
return noOffset && (width === '100%' || height === '100%');
}
// ---------------------------------------------------------------------------
// The walk.
// ---------------------------------------------------------------------------
/** True for an element that marks a page boundary — `compileLayoutToHbs`'s own `.ema-page` wrapper, one per sheet. */
function isPageWrapper(el: ImportedElement): boolean {
return el.attrs.class?.trim().split(/\s+/).includes('ema-page') ?? false;
}
/**
* Converts a parsed HTML/Handlebars document into canvas blocks.
*
* Only the first page's worth of content is imported — a booklet design
* (multiple `.ema-page` sections, as `compileLayoutToHbs` emits one per
* sheet) is reported as unmapped for every page after the first rather than
* guessed at, since nothing before the canvas existed carries a page
* boundary the importer can trust for markup that doesn't use that class.
*/
export function htmlToPlacements(root: ImportedElement, options: ImportOptions = {}): ImportResult {
const pageWidthMm = options.pageWidthMm ?? DEFAULT_PAGE_WIDTH_MM;
const pageHeightMm = options.pageHeightMm ?? DEFAULT_PAGE_HEIGHT_MM;
const placements: TemplateFieldPlacement[] = [];
const unmapped: ImportWarning[] = [];
let backgroundUrl: string | null = null;
let index = 0;
let sawFirstPageWrapper = false;
function walk(el: ImportedElement) {
if (CONTAINER_TAGS.has(el.tag)) {
for (const child of el.children) walk(child);
return;
}
// Every `.ema-page` after the first is a later sheet in a booklet — out
// of scope, see the doc comment above. A source with no `.ema-page` at
// all (every hand-written template except the canvas's own output) has
// nothing to gate here, and walks in full.
if (isPageWrapper(el)) {
if (sawFirstPageWrapper) {
unmapped.push({
element: describe(el),
reason: 'A later page in this booklet was not imported — the canvas imports one page at a time.',
});
return;
}
sawFirstPageWrapper = true;
for (const child of el.children) walk(child);
return;
}
const style = el.style;
const position = style.position?.trim().toLowerCase();
if (el.tag === 'img') {
const src = el.attrs.src ?? '';
if (isBackgroundImage(el.attrs, style)) {
if (!backgroundUrl) backgroundUrl = mustacheImageSource(src) ?? src;
else {
unmapped.push({ element: describe(el), reason: 'A second full-page image was skipped — only one background is supported.' });
}
return;
}
if (position !== 'absolute' && position !== 'fixed') {
unmapped.push({
element: describe(el),
reason: 'Image is not absolutely positioned (no position:absolute with left/top), so it has no fixed spot to place on the canvas.',
});
return;
}
const box = boxFor(style, pageWidthMm, pageHeightMm);
if (!box) {
unmapped.push({
element: describe(el),
reason: 'Image position/width is not a plain length (%, mm, in, px or pt) the canvas can place.',
});
return;
}
const variable = mustacheImageSource(src);
placements.push({
id: blockId(index++),
variable,
type: 'image',
xPct: box.xPct,
yPct: box.yPct,
widthPct: box.widthPct,
});
return;
}
if (position === 'absolute' || position === 'fixed') {
const box = boxFor(style, pageWidthMm, pageHeightMm);
if (!box) {
unmapped.push({
element: describe(el),
reason: 'Position/width is not a plain length (%, mm, in, px or pt) the canvas can place.',
});
// Still walk children — a positioned wrapper around plain
// (non-positioned) content is common hand-authored markup, and its
// children might still resolve their own positions.
for (const child of el.children) walk(child);
return;
}
// This element's own direct text, if any — a leaf like
// `<div style="position:absolute;...">{{holderName}}</div>`. `children`
// only ever holds real elements (text is folded into `.text` by the
// adapter/fixtures), so "no element children" is just an empty array.
const ownText = el.text.trim();
const hasElementChildren = el.children.length > 0;
if (ownText && !hasElementChildren) {
placeText(el, ownText, box);
return;
}
if (!ownText && !hasElementChildren) {
// An empty positioned box — nothing to place, and nothing to warn
// about either (e.g. a spacer div).
return;
}
// A positioned wrapper with element children (not a single text leaf):
// resolvable structure only if it holds exactly one meaningful child,
// which inherits the wrapper's box — a `<div style="position:absolute;...">
// <span>{{x}}</span></div>` pattern real templates sometimes use for a
// style hook. More than one child means real internal layout the
// canvas's flat model cannot represent.
const meaningfulChildren = el.children.filter(
(c) => c.text.trim() || c.children.length || c.tag === 'img',
);
if (meaningfulChildren.length === 1 && !ownText) {
const child = meaningfulChildren[0];
const childText = child.text.trim();
const childHasElementChildren = child.children.length > 0;
if (child.tag === 'img') {
const src = child.attrs.src ?? '';
placements.push({
id: blockId(index++),
variable: mustacheImageSource(src),
type: 'image',
xPct: box.xPct,
yPct: box.yPct,
widthPct: box.widthPct,
});
return;
}
if (childText && !childHasElementChildren) {
placeText({ ...child, style: { ...style, ...child.style } }, childText, box);
return;
}
}
unmapped.push({
element: describe(el),
reason: 'Positioned element holds more than one piece of content (a layout, not a single field) — split it into separate blocks by hand.',
});
return;
}
// Not itself positioned — a plain wrapper (e.g. `<body>`'s children
// before the canvas's own `.ema-page` div). Keep walking; only elements
// that are *never* going to resolve a box report a warning, so a
// structural `<div>` around everything doesn't itself get flagged.
if (el.tag === 'table') {
unmapped.push({
element: describe(el),
reason: 'Tables have no equivalent on the canvas (rows/columns cannot be expressed as fixed boxes) — recreate its cells as individual text blocks, or keep this design in the HTML source tab.',
});
return;
}
if (isFlexOrGrid(style)) {
unmapped.push({
element: describe(el),
reason: 'Flex/grid layout has no equivalent on the canvas — its children\'s positions depend on each other rather than the page, so they were not imported.',
});
return;
}
if (BLOCK_HELPER.test(el.text)) {
unmapped.push({
element: describe(el),
reason: 'Contains a conditional/loop ({{#if}}, {{#each}}, {{else}}) — the canvas can only place a fixed value, not branching logic.',
});
}
for (const child of el.children) walk(child);
}
function placeText(el: ImportedElement, ownText: string, box: Box) {
const mustache = extractVariable(ownText);
placements.push({
id: blockId(index++),
variable: mustache?.variable ?? null,
text: mustache ? undefined : ownText,
type: 'text',
xPct: box.xPct,
yPct: box.yPct,
widthPct: box.widthPct,
fontSize: fontSizePx(el.style),
fontWeight: fontWeight(el.style),
fontStyle: fontStyle(el.style),
align: textAlign(el.style),
color: el.style.color?.trim(),
});
}
walk(root);
return { placements, backgroundUrl, unmapped };
}
/** `{{{variable}}}` inside an `src` (the image convention) → the bare variable name, or null for a literal URL/data URI. */
function mustacheImageSource(src: string): string | null {
const trimmed = src.trim();
const triple = trimmed.match(TRIPLE_MUSTACHE);
if (triple) return triple[1];
const double = trimmed.match(DOUBLE_MUSTACHE);
if (double) return double[1];
return null;
}
function isFlexOrGrid(style: Record<string, string>): boolean {
const display = style.display?.trim().toLowerCase();
return display === 'flex' || display === 'inline-flex' || display === 'grid' || display === 'inline-grid';
}
// ---------------------------------------------------------------------------
// Browser adapter — the only part of this module that touches a real DOM.
// Kept tiny and separate so the extraction logic above stays unit-testable
// with plain object fixtures, matching this app's "pure helpers are tested,
// components/DOM code are not" convention (see vite.config.mts).
// ---------------------------------------------------------------------------
function domElementToImported(el: Element): ImportedElement {
const attrs: Record<string, string> = {};
for (const attr of Array.from(el.attributes)) attrs[attr.name] = attr.value;
let ownText = '';
const children: ImportedElement[] = [];
for (const node of Array.from(el.childNodes)) {
if (node.nodeType === 3 /* Node.TEXT_NODE */) {
ownText += node.textContent ?? '';
} else if (node.nodeType === 1 /* Node.ELEMENT_NODE */) {
children.push(domElementToImported(node as Element));
}
}
return {
tag: el.tagName.toLowerCase(),
style: parseInlineStyle(el.getAttribute('style')),
attrs,
text: ownText,
children,
};
}
/**
* Parses an HTML/Handlebars source string in the browser and extracts canvas
* blocks from it. `pageWidthMm`/`pageHeightMm` should match the design's own
* page size (the designer's current width/height fields) so `%`-based
* lengths — which every EMA-authored template already uses — resolve
* exactly, and physical units (mm/in/px) resolve consistently with it.
*/
export function importHtmlSource(html: string, options: ImportOptions = {}): ImportResult {
const doc = new DOMParser().parseFromString(html, 'text/html');
const root = domElementToImported(doc.documentElement);
return htmlToPlacements(root, options);
}
/**
* The designer's page-size fields (`landscape`, and optionally an explicit
* `width`/`height` CSS length, e.g. `"4.92in"` for the Seaman Book) resolved
* to millimetres, orientation applied. A4 portrait when no explicit size is
* set — the same default the designer itself falls back to (`pageOptionsFor`
* in `designer.ts`).
*/
export function pageDimensionsMm(
landscape: boolean,
pageWidth?: string,
pageHeight?: string,
): { pageWidthMm: number; pageHeightMm: number } {
const explicitW = toMmFromLength(pageWidth);
const explicitH = toMmFromLength(pageHeight);
let widthMm = explicitW ?? DEFAULT_PAGE_WIDTH_MM;
let heightMm = explicitH ?? DEFAULT_PAGE_HEIGHT_MM;
// Portrait is the box's natural reading; landscape swaps it, matching how
// `TemplateCanvas` and Puppeteer's own `landscape` page option both treat
// an explicit width/height pair (as a portrait box, rotated for print).
if (landscape && widthMm < heightMm) [widthMm, heightMm] = [heightMm, widthMm];
if (!landscape && widthMm > heightMm) [widthMm, heightMm] = [heightMm, widthMm];
return { pageWidthMm: widthMm, pageHeightMm: heightMm };
}
/** A CSS length string (e.g. "4.92in", "125mm") to millimetres, or null when absent/unparseable. */
function toMmFromLength(value: string | undefined): number | null {
if (!value?.trim()) return null;
const match = value.trim().match(/^([\d.]+)(mm|cm|in|px|pt)?$/);
if (!match) return null;
const n = Number(match[1]);
if (!Number.isFinite(n) || n <= 0) return null;
return toMm(n, match[2] ?? 'mm');
}

View File

@@ -158,6 +158,19 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
);
}, []);
/**
* Replaces the draft's blocks wholesale — how a one-time HTML-to-canvas
* conversion hands off its result. Distinct from `addBlock`/`updateBlock`,
* which grow or edit the canvas one block at a time; this is the only
* caller that sets the whole array in one step.
*/
const replaceAllBlocks = useCallback((blocks: TemplateFieldPlacement[]) => {
setPlacements(blocks);
setSelectedBlockId(null);
setCurrentPage(0);
setExtraPages(0);
}, []);
const deleteBlock = useCallback((id: string) => {
setPlacements((prev) => prev.filter((block) => block.id !== id));
setSelectedBlockId((current) => (current === id ? null : current));
@@ -213,6 +226,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
addBlock,
updateBlock,
deleteBlock,
replaceAllBlocks,
editorRef,
isPublished,
dirty,

View File

@@ -35,6 +35,7 @@ import { EmptyState, ErrorState, ModalFooter, PageHeader, PdfPreviewModal } from
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
import { DesignerToolbar } from '../components/DesignerToolbar';
import { ImportHtmlModal } from '../components/ImportHtmlModal';
import { NewVersionModal } from '../components/NewVersionModal';
import { TemplateActionBar } from '../components/TemplateActionBar';
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
@@ -44,6 +45,7 @@ import { TemplateVariableList } from '../components/TemplateVariableList';
import { TemplateVersionList } from '../components/TemplateVersionList';
import { pageOptionsFor } from '../config/designer';
import { compileLayoutToHbs } from '../config/layout-compiler';
import { importHtmlSource, pageDimensionsMm, type ImportResult } from '../config/html-import';
import { useDesignerActions } from '../hooks/useDesignerActions';
import { useTemplateDraft } from '../hooks/useTemplateDraft';
import { useTemplatePreview } from '../hooks/useTemplatePreview';
@@ -101,6 +103,7 @@ export function CertificateDesignerPage() {
const [newName, setNewName] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
@@ -140,6 +143,25 @@ export function CertificateDesignerPage() {
setNewOpen(true);
}
/** Parses the current HTML source and opens the report before touching the canvas. */
function importToCanvas() {
const { pageWidthMm, pageHeightMm } = pageDimensionsMm(
draft.landscape,
draft.pageWidth,
draft.pageHeight,
);
setImportResult(importHtmlSource(draft.source, { pageWidthMm, pageHeightMm }));
}
/** Replaces the canvas's blocks/background with what the import found. */
function applyImport() {
if (!importResult) return;
draft.setPlacements(importResult.placements);
if (importResult.backgroundUrl) draft.setBackgroundUrl(importResult.backgroundUrl);
setImportResult(null);
setMode('canvas');
}
const editingLocked = !canEdit || draft.isPublished;
return (
@@ -259,6 +281,16 @@ export function CertificateDesignerPage() {
<Tabs.Panel value="canvas" pt="sm">
<Stack gap="sm">
{!draft.usesCanvas && (
<Paper withBorder p="xs" bg="var(--mantine-color-yellow-light)">
<Text size="xs">
{t(
'designer.sourceOwnsCanvas',
'This design is hand-written HTML (see the HTML source tab) — the canvas below is empty because it has no blocks, not because the design is empty. Adding a block here starts a canvas layout that will replace the hand-written HTML on save.',
)}
</Text>
</Paper>
)}
<TextInput
label={t('designer.name', 'Version name')}
value={draft.name}
@@ -313,6 +345,7 @@ export function CertificateDesignerPage() {
editorRef={draft.editorRef}
disabled={editingLocked}
isPublished={draft.isPublished}
onImportToCanvas={importToCanvas}
/>
</Tabs.Panel>
</Tabs>
@@ -463,6 +496,13 @@ export function CertificateDesignerPage() {
url={previewUrl ?? ''}
title={t('designer.preview', 'Preview')}
/>
<ImportHtmlModal
opened={Boolean(importResult)}
onClose={() => setImportResult(null)}
result={importResult}
onApply={applyImport}
/>
</Container>
);
}

View File

@@ -104,7 +104,17 @@ export function CertificationPage() {
const showError = (e: unknown) => notify.error(extractErrorMessage(e, t('certification.error')));
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
const { data: rankRes } = useGetRanksQuery();
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
// Every CoC rank exists three times: once per STCW tier (Above/Below,
// properly suffixed — see `rankTierSuffix` server-side) plus one untiered
// row kept only so a licence issued before the tier split still resolves
// to a catalogue entry. That legacy row's name carries no tier and reads
// as a plain duplicate ("Chief Mate" next to "Chief Mate — Above/Below"),
// so it is excluded here — a new certification should always be scoped to
// a real, disambiguated rung. CoP ranks have no tier at all and are
// untouched by this filter.
const rankOptions = (rankRes?.items ?? [])
.filter((r) => r.certificateCategory !== 'COC' || r.tier !== null)
.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();

View File

@@ -134,6 +134,33 @@ export function OperationsFormContent({
[operatorOptions, personalOptions],
);
const operatorIds = useMemo(() => operatorOptions.map((t) => t.id), [operatorOptions]);
const personalIds = useMemo(() => personalOptions.map((t) => t.id), [personalOptions]);
/**
* Keeps "company modes" and "individual or vessel owner" mutually
* exclusive — one applicant is either a company representative or a person
* registering for themselves, never both at once (see the heading above:
* running them together reads as though one person could be both). Ticking
* a box in either group clears whatever was checked in the other, rather
* than blocking the click — simplest to use, and nothing here is
* destructive enough to need a confirmation of its own (removal from what
* is actually stored still goes through the existing removal modal below).
* Multiple boxes within one group still combine freely, e.g. Freight
* Forwarder + Shipping Agent, or Seafarer + Vessel Owner.
*/
function handleSelectionChange(next: string[]) {
const addedOperator = next.some((id) => operatorIds.includes(id) && !selected.includes(id));
const addedPersonal = next.some((id) => personalIds.includes(id) && !selected.includes(id));
if (addedOperator) {
setSelected(next.filter((id) => !personalIds.includes(id)));
} else if (addedPersonal) {
setSelected(next.filter((id) => !operatorIds.includes(id)));
} else {
setSelected(next);
}
}
const showDate = useDateDisplayer();
const removed = declaredIds.filter((id) => !selected.includes(id));
const dirty =
@@ -190,7 +217,7 @@ export function OperationsFormContent({
{t('profileOperations.description')}
</Text>
<Checkbox.Group value={selected} onChange={setSelected}>
<Checkbox.Group value={selected} onChange={handleSelectionChange}>
<Stack gap="sm">
{operatorOptions.map((type) => (
<Checkbox
@@ -215,12 +242,25 @@ export function OperationsFormContent({
{/* Kept apart from the company modes above: declaring "I am a
seafarer" is a different kind of statement from "my company
forwards freight", and running them together reads as though
one person could be both at once. */}
one person could be both at once — enforced by
`handleSelectionChange`, which clears this group as soon as a
company mode above is checked, and vice versa. */}
{personalOptions.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" mt="sm" tt="uppercase">
Registering or applying as an individual or vessel owner
{t(
'profileOperations.personalSectionTitle',
'Registering or applying as an individual or vessel owner',
)}
</Text>
{operatorOptions.length > 0 && (
<Text size="xs" c="dimmed">
{t(
'profileOperations.personalSectionHint',
'Selecting one of these clears any company modes above — pick one or the other, not both.',
)}
</Text>
)}
{personalOptions.map((type) => (
<Checkbox
key={type.id}
@@ -230,7 +270,7 @@ export function OperationsFormContent({
<Text size="sm">{localized(type.name)}</Text>
{declaredIds.includes(type.id) && (
<Badge size="xs" variant="light" color="teal">
Current
{t('profileOperations.current')}
</Badge>
)}
</Group>

View File

@@ -904,6 +904,8 @@ export const am: Translations = {
profileOperations: {
title: 'የስራ ዘርፍ',
description: 'ድርጅትዎ በምን ዘርፍ እንደሚሰራ። የትኞቹ ፈቃዶች እንደሚቀርቡልዎት የሚወስነው ይህ ነው — ንግድዎ ሲቀየር ማንኛውም ጊዜ መቀየር ይችላሉ።',
personalSectionTitle: 'እንደ ግለሰብ ወይም የመርከብ ባለቤት በመመዝገብ ወይም በማመልከት',
personalSectionHint: 'ከእነዚህ ውስጥ አንዱን መምረጥ ከላይ ያሉትን የድርጅት ዘርፎች ያጠፋል — ከሁለቱ አንዱን ብቻ ይምረጡ።',
current: 'የአሁኑ',
emptyState: 'እስካሁን የተዋቀሩ የፈቃድ ዓይነቶች የሉም። የሚጠብቁት ካለ EMA ን ያነጋግሩ።',
noneSelectedTitle: 'ምንም የስራ ዘርፍ አልተመረጠም',

View File

@@ -910,6 +910,8 @@ export const en = {
profileOperations: {
title: 'Mode of operation',
description: 'What your company operates as. This decides which licences you are offered — you can change it whenever your business changes.',
personalSectionTitle: 'Registering or applying as an individual or vessel owner',
personalSectionHint: 'Selecting one of these clears any company modes above — pick one or the other, not both.',
current: 'Current',
emptyState: 'No licence types are configured yet. Contact EMA if you were expecting one.',
noneSelectedTitle: 'No operations selected',

View File

@@ -821,18 +821,29 @@ export interface Department {
export type RankCertificateCategory = "COC" | "COP";
/**
* Which STCW ship-size/power limitation a CoC rank row is issued under —
* every CoC rank exists twice, once per tier, plus a third untiered row kept
* only so licences issued before the tier split still resolve to a catalogue
* entry. `null` for a CoP rank (no such limitation applies) and for that
* untiered legacy CoC row — never for a rank meant to be offered as a new
* choice going forward.
*/
export type RankTier = "ABOVE" | "BELOW";
/** One rung of a CoC/CoP ladder for a department. */
export interface Rank {
id: string;
departmentId: string;
certificateCategory: RankCertificateCategory;
/** Value stored on License.rank / Certification.rankKey, e.g. "CHIEF_MATE". */
/** Value stored on License.rank / Certification.rankKey, e.g. "CHIEF_MATE_ABOVE". */
key: string;
name: Bilingual;
/** Rung position within its department+category ladder. 0 is the entry rank. */
ladderOrder: number;
sortOrder: number;
isActive: boolean;
tier: RankTier | null;
}
export interface LicenseTemplate {