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(null); const [rankId, setRankId] = useState(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(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 ( { 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 ? ( refetch()} icon={IconAlertCircle} /> ) : !isLoading && templates.length === 0 ? ( ) : ( {/* 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 && ( } title={t('designer.liveDesign', 'This is the live design')} > {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.', )} {canEdit && ( )} )} setMode(value as 'canvas' | 'source')}> }> {t('designer.tabCanvas', 'Visual editor')} }> {t('designer.tabSource', 'HTML source')} draft.setName(e.currentTarget.value)} disabled={editingLocked} /> {draft.usesCanvas && ( {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.', )} )} 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'), ) } /> draft.addBlock(key)} onAddTextBlock={() => draft.addBlock(null, 'Text')} /> )} 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')) } /> ); } export default CertificateDesignerPage;