mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
432 lines
17 KiB
TypeScript
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;
|