Files
emaui/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
2026-08-24 08:20:13 +00:00

432 lines
17 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
Alert,
Button,
Container,
Group,
Paper,
Stack,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import {
IconAlertCircle,
IconCode,
IconLayoutBoard,
IconLock,
IconPlus,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
extractErrorMessage,
useArchiveLicenseTemplateMutation,
useCreateLicenseTemplateMutation,
useDeleteLicenseTemplateMutation,
useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetRanksQuery,
useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
} from '@ema-platform/api';
import { EmptyState, ErrorState, 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';
import { NewVersionModal } from '../components/NewVersionModal';
import { TemplateActionBar } from '../components/TemplateActionBar';
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
import { TemplateCanvas } from '../components/TemplateCanvas';
import { TemplateEditor } from '../components/TemplateEditor';
import { TemplateVariableList } from '../components/TemplateVariableList';
import { TemplateVersionList } from '../components/TemplateVersionList';
import { pageOptionsFor } from '../config/designer';
import { compileLayoutToHbs } from '../config/layout-compiler';
import { useDesignerActions } from '../hooks/useDesignerActions';
import { useTemplateDraft } from '../hooks/useTemplateDraft';
import { useTemplatePreview } from '../hooks/useTemplatePreview';
/**
* Where the authority designs the certificate its licensees receive.
*
* The layout used to be a Handlebars file inside the deployed image, so any
* change to the authority's own certificate needed a developer and a release.
* Here it is data: staff author a version, preview the real PDF, and publish.
* Publishing archives the incumbent, so exactly one design is live per licence
* type and previously issued certificates keep the design they were made from.
*/
export function CertificateDesignerPage() {
const { t } = useTranslation();
const { can } = usePermissions();
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]);
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null);
const [rankId, setRankId] = useState<string | null>(null);
const {
data: allTemplates = [],
isLoading,
isError,
error,
refetch,
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
// The list is per licence type; a rank-specific design and the type's
// 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();
const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation();
const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation();
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
const draft = useTemplateDraft(templates);
const run = useDesignerActions();
const { previewUrl, open: openPreview, close: closePreview } = useTemplatePreview();
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const [validityMonths, setValidityMonths] = useState<number>(12);
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.
const rankCategory: 'COC' | 'COP' | null =
selectedType?.key === 'CERTIFICATE_OF_COMPETENCY'
? 'COC'
: selectedType?.key === 'CERTIFICATE_OF_PROFICIENCY'
? 'COP'
: null;
const isRankScoped = rankCategory !== null;
const { data: allRanks } = useGetRanksQuery(undefined, { skip: !isRankScoped });
const ranks = (allRanks?.items ?? [])
.filter((r) => r.certificateCategory === rankCategory)
.sort((a, b) => a.sortOrder - b.sortOrder);
// Default to the first licence type so the page is never an empty shell.
useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
}, [licenseTypes, typeId]);
useEffect(() => {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
// Switching licence type leaves a stale rank selected from the previous
// type's ladder — reset to the type's default design.
useEffect(() => {
setRankId(null);
}, [typeId]);
function startNewVersion() {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
);
setNewOpen(true);
}
const editingLocked = !canEdit || draft.isPublished;
return (
<Container size="xl" py="md">
<PageHeader
title={t('designer.title', 'Certificate designer')}
subtitle={t(
'designer.subtitle',
'Design the certificate issued to licence holders, and set how long it stays valid.',
)}
/>
<DesignerToolbar
licenseTypes={licenseTypes?.items ?? []}
typeId={typeId}
onTypeChange={(value) => {
setTypeId(value);
draft.setSelectedId(null);
}}
ranks={ranks}
rankId={rankId}
onRankChange={(value) => {
setRankId(value);
draft.setSelectedId(null);
}}
validityMonths={validityMonths}
onValidityChange={setValidityMonths}
currentValidityMonths={selectedType?.validityMonths}
canEdit={canEdit}
savingValidity={savingValidity}
onSaveValidity={() =>
run(
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
t('designer.validitySaved', 'Validity updated'),
)
}
onNewVersion={startNewVersion}
/>
{isError ? (
<ErrorState
title={t('designer.loadFailed', 'Could not load the designs')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
icon={IconAlertCircle}
/>
) : !isLoading && templates.length === 0 ? (
<EmptyState
title={t('designer.empty', 'No design yet for this licence type')}
description={t(
'designer.emptyBody',
'Certificates currently use the built-in layout. Create a version to take control of it.',
)}
action={
canEdit
? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion }
: undefined
}
/>
) : (
<Group align="flex-start" gap="md" wrap="nowrap">
<TemplateVersionList
templates={templates}
selectedId={draft.selectedId}
onSelect={draft.setSelectedId}
/>
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<TemplateBackgroundPanel
backgroundUrl={draft.backgroundUrl}
onBackgroundChange={draft.setBackgroundUrl}
logoUrl={draft.logoUrl}
onLogoChange={draft.setLogoUrl}
logoPlacement={draft.logoPlacement}
onLogoPlacementChange={draft.setLogoPlacement}
landscape={draft.landscape}
onLandscapeChange={draft.setLandscape}
disabled={editingLocked}
/>
{/* A published design is immutable by rule -- certificates were
issued from it -- so the editor is locked. Without this the
screen looks broken rather than deliberately read-only, which
is what "edit is not available" turns out to mean. */}
{draft.isPublished && (
<Alert
variant="light"
color="blue"
icon={<IconLock size={18} />}
title={t('designer.liveDesign', 'This is the live design')}
>
<Stack gap="xs" align="flex-start">
<Text size="sm">
{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.',
)}
</Text>
{canEdit && (
<Button
size="xs"
leftSection={<IconPlus size={14} />}
onClick={startNewVersion}
>
{t('designer.newVersionFromThis', 'New version from this design')}
</Button>
)}
</Stack>
</Alert>
)}
<Tabs value={mode} onChange={(value) => setMode(value as 'canvas' | 'source')}>
<Tabs.List>
<Tabs.Tab value="canvas" leftSection={<IconLayoutBoard size={15} />}>
{t('designer.tabCanvas', 'Visual editor')}
</Tabs.Tab>
<Tabs.Tab value="source" leftSection={<IconCode size={15} />}>
{t('designer.tabSource', 'HTML source')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="canvas" pt="sm">
<Stack gap="sm">
<TextInput
label={t('designer.name', 'Version name')}
value={draft.name}
onChange={(e) => draft.setName(e.currentTarget.value)}
disabled={editingLocked}
/>
<TemplateCanvas
backgroundUrl={draft.backgroundUrl}
logoUrl={draft.logoUrl}
logoPlacement={draft.logoPlacement}
landscape={draft.landscape}
placements={draft.placements}
selectedId={draft.selectedBlockId}
onSelect={draft.setSelectedBlockId}
onChange={draft.setPlacements}
disabled={editingLocked}
/>
<BlockPropertiesPanel
block={draft.selectedBlock}
variables={variables}
onChange={draft.updateBlock}
onDelete={draft.deleteBlock}
disabled={editingLocked}
/>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="source" pt="sm">
{draft.usesCanvas && (
<Paper withBorder p="xs" mb="sm" bg="var(--mantine-color-yellow-light)">
<Text size="xs">
{t(
'designer.canvasOwnsSource',
'This design is laid out on the visual editor, which regenerates the HTML on every save. Edits made here will be overwritten — remove all blocks first to hand-write the template.',
)}
</Text>
</Paper>
)}
<TemplateEditor
name={draft.name}
onNameChange={draft.setName}
source={draft.source}
onSourceChange={draft.setSource}
landscape={draft.landscape}
onLandscapeChange={draft.setLandscape}
editorRef={draft.editorRef}
disabled={editingLocked}
isPublished={draft.isPublished}
/>
</Tabs.Panel>
</Tabs>
<TemplateActionBar
// A canvas layout has no Handlebars source until it is saved --
// the server compiles it -- so gating preview on the source alone
// left the button dead for exactly the designs the canvas is for.
hasSource={Boolean(draft.source.trim()) || draft.usesCanvas}
hasSelection={Boolean(draft.selected)}
isPublished={draft.isPublished}
dirty={draft.dirty}
canEdit={canEdit}
canPublish={canPublish}
saving={saving}
publishing={publishing}
onPreview={() =>
openPreview({
// A canvas layout is compiled here so the preview shows
// unsaved block moves; the server compiles the stored copy.
hbsSource: draft.usesCanvas
? compileLayoutToHbs({
backgroundUrl: draft.backgroundUrl,
logoUrl: draft.logoUrl,
logoPlacement: draft.logoPlacement,
fieldPlacements: draft.placements,
})
: draft.source,
licenseTypeId: typeId,
landscape: draft.landscape,
})
}
onSave={() =>
run(
() =>
updateTemplate({
id: draft.selected!.id,
name: draft.name,
// The server recompiles the HTML from the blocks when a
// canvas layout is present, so sending the stale source
// alongside it would only fight that.
hbsSource: draft.usesCanvas ? undefined : draft.source,
pageOptions: pageOptionsFor(draft.landscape),
backgroundUrl: draft.backgroundUrl || undefined,
logoUrl: draft.logoUrl || undefined,
logoPlacement: draft.logoPlacement,
fieldPlacements: draft.placements,
}).unwrap(),
t('designer.saved', 'Draft saved'),
)
}
onPublish={() =>
run(
() => publishTemplate(draft.selected!.id).unwrap(),
t('designer.published', 'Design published'),
)
}
onArchive={() =>
run(
() => archiveTemplate(draft.selected!.id).unwrap(),
t('designer.archived', 'Design withdrawn'),
)
}
onDelete={() =>
run(
() => deleteTemplate(draft.selected!.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
)
}
/>
</Stack>
<TemplateVariableList
variables={variables}
disabled={editingLocked}
canvasMode={mode === 'canvas'}
onInsert={draft.insertVariable}
onAddBlock={(key) => draft.addBlock(key)}
onAddTextBlock={() => draft.addBlock(null, 'Text')}
/>
</Group>
)}
<NewVersionModal
opened={newOpen}
name={newName}
onNameChange={setNewName}
creating={creating}
onClose={() => setNewOpen(false)}
onCreate={() =>
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
rankId,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();
draft.setSelectedId(created.id);
setNewOpen(false);
}, t('designer.created', 'Draft created'))
}
/>
<PdfPreviewModal
opened={Boolean(previewUrl)}
onClose={closePreview}
url={previewUrl ?? ''}
title={t('designer.preview', 'Preview')}
/>
</Container>
);
}
export default CertificateDesignerPage;