diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx new file mode 100644 index 000000000..46b801648 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -0,0 +1,560 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ActionIcon, + Badge, + Button, + Card, + Code, + Container, + Group, + Modal, + NumberInput, + Paper, + ScrollArea, + Select, + Stack, + Switch, + Text, + TextInput, + Textarea, + Title, + Tooltip, +} from '@mantine/core'; +import { + IconAlertCircle, + IconDeviceFloppy, + IconEye, + IconPlus, + IconRosetteDiscountCheck, + IconTrash, +} from '@tabler/icons-react'; +import { notifications } from '@mantine/notifications'; +import { useTranslation } from 'react-i18next'; +import { + extractErrorMessage, + useArchiveLicenseTemplateMutation, + useCreateLicenseTemplateMutation, + useDeleteLicenseTemplateMutation, + useGetBuiltInTemplateQuery, + useGetLicenseTemplatesQuery, + useGetLicenseTypesQuery, + useGetTemplateVariablesQuery, + usePublishLicenseTemplateMutation, + useUpdateLicenseValidityMutation, + useUpdateLicenseTemplateMutation, + type LicenseTemplate, +} from '@ema-platform/api'; +import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui'; +import { authStorage, usePermissions } from '@ema-platform/auth'; +import { PERMISSIONS } from '../../../layouts/nav-config'; + +/** Same resolution the shared RTK Query baseQuery uses. */ +const API_BASE_URL = + (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? + 'http://localhost:3000/api'; + +const STATUS_COLOR: Record = { + DRAFT: 'gray', + PUBLISHED: 'teal', + ARCHIVED: 'dark', +}; + +/** + * 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 { + data: templates = [], + isLoading, + isError, + error, + refetch, + } = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId }); + 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 [selectedId, setSelectedId] = useState(null); + const [source, setSource] = useState(''); + const [name, setName] = useState(''); + const [landscape, setLandscape] = useState(true); + const [newOpen, setNewOpen] = useState(false); + const [newName, setNewName] = useState(''); + const editorRef = useRef(null); + + const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); + const [validityMonths, setValidityMonths] = useState(12); + + // 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]); + + const selected = useMemo( + () => templates.find((tpl) => tpl.id === selectedId) ?? null, + [templates, selectedId], + ); + + // Pick the live design by default — that is the one staff usually want. + useEffect(() => { + if (!templates.length) { + setSelectedId(null); + return; + } + if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return; + const published = templates.find((tpl) => tpl.status === 'PUBLISHED'); + setSelectedId((published ?? templates[0]).id); + }, [templates, selectedId]); + + useEffect(() => { + if (!selected) return; + setSource(selected.hbsSource); + setName(selected.name); + setLandscape(selected.pageOptions?.landscape ?? true); + }, [selected]); + + const isPublished = selected?.status === 'PUBLISHED'; + const dirty = + Boolean(selected) && + (source !== selected?.hbsSource || + name !== selected?.name || + landscape !== (selected?.pageOptions?.landscape ?? true)); + + async function run(action: () => Promise, success: string) { + try { + await action(); + notifications.show({ color: 'teal', title: success, message: '' }); + } catch (err) { + notifications.show({ + color: 'red', + title: t('designer.actionFailed', 'Action failed'), + message: extractErrorMessage(err), + }); + } + } + + /** Inserts a placeholder where the caret is, rather than at the end. */ + function insertVariable(key: string) { + const el = editorRef.current; + const token = `{{${key}}}`; + if (!el) { + setSource((prev) => prev + token); + return; + } + const start = el.selectionStart ?? source.length; + const end = el.selectionEnd ?? start; + setSource(source.slice(0, start) + token + source.slice(end)); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(start + token.length, start + token.length); + }); + } + + /** + * Renders the editor's current contents, not the saved row, so unsaved edits + * are what you see. Opened as a blob so it never leaves a file behind. + */ + async function preview() { + try { + // The preview returns a PDF stream, not JSON, so it bypasses RTK Query + // and calls the API directly — which means spelling out the base URL and + // the bearer token that the shared baseQuery would normally attach. + const token = authStorage.getToken(); + const response = await fetch(`${API_BASE_URL}/license-templates/preview`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + hbsSource: source, + licenseTypeId: typeId, + pageOptions: { format: 'A4', landscape, printBackground: true }, + }), + }); + if (!response.ok) throw new Error(await response.text()); + const url = URL.createObjectURL(await response.blob()); + window.open(url, '_blank', 'noopener'); + // Give the new tab time to read it before revoking. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch (err) { + notifications.show({ + color: 'red', + title: t('designer.previewFailed', 'Could not render the preview'), + message: extractErrorMessage(err), + }); + } + } + + return ( + + + + +