mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 04:48:19 +00:00
feat: implement STCW certificate template conversion to canvas blocks
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import type { TemplateFieldPlacement } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* One-time, one-way conversion of the hand-written STCW certificate template
|
||||
* (`SEAFARER_CERTIFICATE_TEMPLATE` on the server) into an equivalent canvas
|
||||
* layout.
|
||||
*
|
||||
* This is not a general HTML-to-blocks parser — none exists, and none is
|
||||
* feasible: the canvas only ever emits absolute-positioned text/image blocks
|
||||
* (see `layout-compiler.ts`), so a `<table>`, a `{{#if}}` fallback chain, or a
|
||||
* border/outline rule has no block equivalent to convert to. What this does
|
||||
* is reproduce *this one template's* known field positions as blocks, by
|
||||
* hand, from its own `mm`-based CSS — an approximation good enough to keep
|
||||
* editing visually from here, not a lossless round-trip. Once converted, the
|
||||
* canvas becomes the new source of truth for the draft, same as any
|
||||
* canvas-authored design; the original HTML is gone unless the draft is
|
||||
* reverted before saving.
|
||||
*
|
||||
* What is lost, deliberately, by this conversion:
|
||||
* - The bordered/outlined sheet frame, the `facts` table's row lines, and
|
||||
* every font/spacing rule in the template's `<style>` block — the canvas
|
||||
* has no border or table primitive, only positioned text and images.
|
||||
* - The rank/proficiency fallback chain
|
||||
* (`rank ?? rankEngine ?? proficiencyDeck ?? proficiencyEngine ?? proficiencyOther`)
|
||||
* collapses to a single field (`form.certificate.rank`) — a CoP whose
|
||||
* applicant used one of the other fields will need that block's variable
|
||||
* changed by hand after conversion.
|
||||
* - The "CERTIFICATE OF {COMPETENCY|PROFICIENCY}" title conditional becomes a
|
||||
* fixed literal ("CERTIFICATE OF COMPETENCY / PROFICIENCY") — the same
|
||||
* template is seeded for both licence types, and a canvas block cannot
|
||||
* branch on `licenseTypeName` the way the Handlebars `{{#if}}` did.
|
||||
* - Amharic/English authority header text becomes two literal text blocks
|
||||
* rather than being drawn from the template source, so it can be edited or
|
||||
* removed like any other block.
|
||||
*/
|
||||
|
||||
/** Guard: only offer the conversion for a draft that is actually this template, not any hand-written source. */
|
||||
export function looksLikeStcwTemplate(hbsSource: string): boolean {
|
||||
return (
|
||||
hbsSource.includes('has met the STCW requirements') &&
|
||||
hbsSource.includes('table class="facts"') &&
|
||||
hbsSource.includes('{{holderName}}')
|
||||
);
|
||||
}
|
||||
|
||||
const SHEET_WIDTH_MM = 297;
|
||||
const SHEET_HEIGHT_MM = 210;
|
||||
|
||||
function xPct(mm: number): number {
|
||||
return Math.round((mm / SHEET_WIDTH_MM) * 1000) / 10;
|
||||
}
|
||||
function yPct(mm: number): number {
|
||||
return Math.round((mm / SHEET_HEIGHT_MM) * 1000) / 10;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
/** Stable-enough ids for a batch created in one call — matches the `blk_` scheme `useTemplateDraft` uses elsewhere. */
|
||||
function blockId(): string {
|
||||
counter += 1;
|
||||
return `blk_stcw${Date.now().toString(36)}${counter}`;
|
||||
}
|
||||
|
||||
function text(
|
||||
variable: string | null,
|
||||
content: string | undefined,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
opts: Partial<TemplateFieldPlacement> = {},
|
||||
): TemplateFieldPlacement {
|
||||
return {
|
||||
id: blockId(),
|
||||
variable,
|
||||
text: content,
|
||||
type: 'text',
|
||||
xPct: x,
|
||||
yPct: y,
|
||||
widthPct: width,
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
align: 'left',
|
||||
color: '#111111',
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
function image(
|
||||
variable: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
): TemplateFieldPlacement {
|
||||
return { id: blockId(), variable, type: 'image', xPct: x, yPct: y, widthPct: width };
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the block layout. Takes `licenseTypeName` only to pick the fixed
|
||||
* title text (see the class doc above) — everything else is positional.
|
||||
*/
|
||||
export function convertStcwTemplateToBlocks(licenseTypeName: string): TemplateFieldPlacement[] {
|
||||
const isProficiency = licenseTypeName.toLowerCase().includes('proficiency');
|
||||
const blocks: TemplateFieldPlacement[] = [];
|
||||
|
||||
// Header: authority name, centered-ish block near the top. The template's
|
||||
// flex-centered header has no block equivalent, so this is placed by eye.
|
||||
blocks.push(
|
||||
text(null, 'የኢትዮጵያ ማሪታይም ባለሥልጣን', 30, 6, 40, {
|
||||
align: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
}),
|
||||
text(null, 'ETHIOPIAN MARITIME AUTHORITY', 30, 9.5, 40, {
|
||||
align: 'center',
|
||||
fontSize: 15,
|
||||
fontWeight: 'bold',
|
||||
color: '#0b3d6b',
|
||||
}),
|
||||
text(null, 'Federal Democratic Republic of Ethiopia', 30, 13, 40, {
|
||||
align: 'center',
|
||||
fontSize: 9,
|
||||
color: '#5b6b7f',
|
||||
}),
|
||||
image('logo', 8, 6, 15),
|
||||
);
|
||||
|
||||
// Title
|
||||
blocks.push(
|
||||
text(
|
||||
null,
|
||||
`CERTIFICATE OF ${isProficiency ? 'PROFICIENCY' : 'COMPETENCY'}`,
|
||||
15,
|
||||
21,
|
||||
70,
|
||||
{ align: 'center', fontSize: 20, fontWeight: 'bold', color: '#0b3d6b' },
|
||||
),
|
||||
text('licenseTypeName', undefined, 15, 25.5, 70, {
|
||||
align: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
color: '#c9a227',
|
||||
}),
|
||||
);
|
||||
|
||||
// Holder
|
||||
blocks.push(
|
||||
text(null, 'This is to certify that', 15, 30, 70, { align: 'center', fontSize: 10, color: '#34495e' }),
|
||||
image('holderPhoto', 40, 34, 12),
|
||||
text('holderName', undefined, 15, 42, 70, { align: 'center', fontSize: 17, fontWeight: 'bold' }),
|
||||
);
|
||||
// Seafarer number / nationality are two conditional fragments in the
|
||||
// original — placed here as their own blocks rather than forced into one,
|
||||
// so either can be deleted independently if a design does not need it.
|
||||
blocks.push(
|
||||
text('seafarerNumber', undefined, 15, 50, 34, { align: 'center', fontSize: 9.5, color: '#5b6b7f' }),
|
||||
text('holderNationality', undefined, 51, 50, 34, { align: 'center', fontSize: 9.5, color: '#5b6b7f' }),
|
||||
);
|
||||
|
||||
blocks.push(
|
||||
text(
|
||||
null,
|
||||
'has met the STCW requirements and is entitled to serve in the capacity stated below, in accordance with the regulations of the Ethiopian Maritime Authority.',
|
||||
15,
|
||||
54,
|
||||
70,
|
||||
{ align: 'center', fontSize: 10, color: '#34495e' },
|
||||
),
|
||||
);
|
||||
|
||||
// Facts — the original table becomes six label/value pairs, two columns.
|
||||
// Labels are literal text; the label column's own header cell ("Certificate
|
||||
// No.", "Department", …) has no variable, so those are plain `text` blocks.
|
||||
const rowY = [64, 68, 72];
|
||||
const col = [
|
||||
{ labelX: 16, valueX: 30 },
|
||||
{ labelX: 55, valueX: 68 },
|
||||
];
|
||||
const facts: { label: string; variable: string; row: number; col: number }[] = [
|
||||
{ label: 'Certificate No.', variable: 'certificateNumber', row: 0, col: 0 },
|
||||
{ label: 'Department', variable: 'form.certificate.department', row: 0, col: 1 },
|
||||
// Approximates the original's rank/proficiency fallback chain with a
|
||||
// single field — see the module doc for what this loses on a CoP.
|
||||
{ label: 'Rank / Capacity', variable: 'form.certificate.rank', row: 1, col: 0 },
|
||||
{ label: 'Date of Issue', variable: 'issueDate', row: 1, col: 1 },
|
||||
{ label: 'Application No.', variable: 'applicationNumber', row: 2, col: 0 },
|
||||
{ label: 'Valid Until', variable: 'expiryDate', row: 2, col: 1 },
|
||||
];
|
||||
for (const fact of facts) {
|
||||
const { labelX, valueX } = col[fact.col];
|
||||
const y = rowY[fact.row];
|
||||
blocks.push(
|
||||
text(null, fact.label, labelX, y, 13, { fontSize: 9.5, color: '#5b6b7f' }),
|
||||
text(fact.variable, undefined, valueX, y, 20, { fontSize: 9.5, fontWeight: 'bold' }),
|
||||
);
|
||||
}
|
||||
// Limitations, conditional in the source — kept as its own row so deleting
|
||||
// the block is how a design without a Limitations line removes it.
|
||||
blocks.push(
|
||||
text(null, 'Limitations', 16, 78, 13, { fontSize: 9.5, color: '#5b6b7f' }),
|
||||
text('rankLimitation', undefined, 30, 78, 58, { fontSize: 9.5, fontWeight: 'bold' }),
|
||||
);
|
||||
|
||||
// Footer: seal, signature block, QR — matching the original's left-16mm,
|
||||
// right-16mm, bottom-12mm footer band.
|
||||
blocks.push(
|
||||
image('sealImage', 18, yPct(210 - 12 - 28), 12),
|
||||
// The horizontal "signed above this line" rule under the signature image
|
||||
// has no block equivalent (the canvas draws no shape primitives) and is
|
||||
// dropped rather than faked with an empty text block.
|
||||
image('signatureImage', 42, yPct(210 - 12 - 20), 16),
|
||||
text('approverName', undefined, 38, yPct(210 - 12), 24, { align: 'center', fontSize: 9, fontWeight: 'bold' }),
|
||||
text('approverRole', undefined, 38, yPct(210 - 12 + 3.5), 24, { align: 'center', fontSize: 8, color: '#5b6b7f' }),
|
||||
image('qrImage', xPct(297 - 16 - 26), yPct(210 - 12 - 26), 9),
|
||||
text(null, 'Scan to verify', xPct(297 - 16 - 34), yPct(210 - 12), 12, {
|
||||
align: 'center',
|
||||
fontSize: 7,
|
||||
color: '#5b6b7f',
|
||||
}),
|
||||
);
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
IconLayoutBoard,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconWand,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -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 { convertStcwTemplateToBlocks, looksLikeStcwTemplate } from '../config/stcw-canvas-conversion';
|
||||
import { useDesignerActions } from '../hooks/useDesignerActions';
|
||||
import { useTemplateDraft } from '../hooks/useTemplateDraft';
|
||||
import { useTemplatePreview } from '../hooks/useTemplatePreview';
|
||||
@@ -100,6 +102,7 @@ export function CertificateDesignerPage() {
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [convertOpen, setConvertOpen] = useState(false);
|
||||
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
@@ -259,6 +262,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}
|
||||
@@ -303,6 +316,26 @@ export function CertificateDesignerPage() {
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
{!draft.usesCanvas && looksLikeStcwTemplate(draft.source) && !editingLocked && (
|
||||
<Paper withBorder p="xs" mb="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'designer.convertHint',
|
||||
'Recognised as the STCW certificate layout — it can be converted to visual-editor blocks to keep editing it there.',
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconWand size={14} />}
|
||||
onClick={() => setConvertOpen(true)}
|
||||
>
|
||||
{t('designer.convertToCanvas', 'Convert to canvas')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
<TemplateEditor
|
||||
name={draft.name}
|
||||
onNameChange={draft.setName}
|
||||
@@ -457,6 +490,65 @@ export function CertificateDesignerPage() {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={convertOpen}
|
||||
onClose={() => setConvertOpen(false)}
|
||||
title={t('designer.convertToCanvas', 'Convert to canvas')}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'designer.convertExplain',
|
||||
'This reproduces the certificate’s fields as visual-editor blocks so you can keep editing it there. It is an approximation, not an exact copy:',
|
||||
)}
|
||||
</Text>
|
||||
<Stack gap={4} component="ul" style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<Text component="li" size="sm">
|
||||
{t(
|
||||
'designer.convertLoseBorder',
|
||||
'The sheet border, outline and table row lines are dropped — the canvas has no border or table to draw them with.',
|
||||
)}
|
||||
</Text>
|
||||
<Text component="li" size="sm">
|
||||
{t(
|
||||
'designer.convertLoseFallback',
|
||||
'A Certificate of Proficiency’s rank/proficiency field collapses to one block (Rank / Capacity) — if the applicant used a different field, that block’s variable needs changing by hand.',
|
||||
)}
|
||||
</Text>
|
||||
<Text component="li" size="sm">
|
||||
{t(
|
||||
'designer.convertLoseTitle',
|
||||
'The "Certificate of Competency / Proficiency" title becomes fixed text for whichever licence type you are converting from — it no longer switches automatically.',
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="sm" fw={600}>
|
||||
{t(
|
||||
'designer.convertIrreversible',
|
||||
'Once converted, the canvas becomes this draft’s design — the hand-written HTML is replaced on save.',
|
||||
)}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setConvertOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconWand size={14} />}
|
||||
onClick={() => {
|
||||
draft.replaceAllBlocks(
|
||||
convertStcwTemplateToBlocks(selectedType?.name?.en ?? 'Certificate of Competency'),
|
||||
);
|
||||
setConvertOpen(false);
|
||||
setMode('canvas');
|
||||
}}
|
||||
>
|
||||
{t('designer.convertToCanvas', 'Convert to canvas')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(previewUrl)}
|
||||
onClose={closePreview}
|
||||
|
||||
Reference in New Issue
Block a user