mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 10:58:16 +00:00
Initial End to End functionality
This commit is contained in:
@@ -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<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
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<string | null>(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<string | null>(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<HTMLTextAreaElement>(null);
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
const [validityMonths, setValidityMonths] = useState<number>(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<unknown>, 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 (
|
||||
<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.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Group align="flex-end" mb="md" gap="sm">
|
||||
<Select
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name?.en ?? type.key,
|
||||
}))}
|
||||
value={typeId}
|
||||
onChange={(value) => {
|
||||
setTypeId(value);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
w={280}
|
||||
/>
|
||||
|
||||
{/* Validity lives beside the design because it is the other half of
|
||||
what a certificate promises. */}
|
||||
<NumberInput
|
||||
label={t('designer.validityYears', 'Valid for (years)')}
|
||||
description={t('designer.validityHint', 'Applied when a licence is issued')}
|
||||
value={Number((validityMonths / 12).toFixed(2))}
|
||||
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
decimalScale={1}
|
||||
w={190}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
canEdit
|
||||
? t('designer.saveValidity', 'Save validity')
|
||||
: t('designer.noPermission', 'You do not have permission')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={savingValidity}
|
||||
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||
t('designer.validitySaved', 'Validity updated'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.saveValidity', 'Save validity')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
disabled={!canEdit || !typeId}
|
||||
onClick={() => {
|
||||
setNewName(
|
||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||
);
|
||||
setNewOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('designer.newVersion', 'New version')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{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: () => {
|
||||
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
|
||||
setNewOpen(true);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Group align="flex-start" gap="md" wrap="nowrap">
|
||||
{/* Versions */}
|
||||
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.versions', 'Versions')}
|
||||
</Text>
|
||||
{templates.map((tpl) => (
|
||||
<Card
|
||||
key={tpl.id}
|
||||
withBorder
|
||||
padding="xs"
|
||||
onClick={() => setSelectedId(tpl.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor:
|
||||
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{tpl.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{tpl.version}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
|
||||
{tpl.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Editor */}
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="sm" align="flex-end">
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
disabled={!canEdit || isPublished}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Switch
|
||||
label={t('designer.landscape', 'Landscape')}
|
||||
checked={landscape}
|
||||
onChange={(e) => setLandscape(e.currentTarget.checked)}
|
||||
disabled={!canEdit || isPublished}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isPublished && (
|
||||
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'designer.publishedLocked',
|
||||
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={editorRef}
|
||||
label={t('designer.source', 'Template (Handlebars + HTML)')}
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.currentTarget.value)}
|
||||
disabled={!canEdit || isPublished}
|
||||
autosize
|
||||
minRows={18}
|
||||
maxRows={30}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconEye size={16} />}
|
||||
onClick={preview}
|
||||
disabled={!source.trim()}
|
||||
>
|
||||
{t('designer.preview', 'Preview PDF')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
loading={saving}
|
||||
disabled={!canEdit || isPublished || !dirty}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
updateTemplate({
|
||||
id: selected!.id,
|
||||
name,
|
||||
hbsSource: source,
|
||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
||||
}).unwrap(),
|
||||
t('designer.saved', 'Draft saved'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.save', 'Save draft')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
!canPublish
|
||||
? t('designer.noPublishPermission', 'You cannot publish designs')
|
||||
: dirty
|
||||
? t('designer.saveFirst', 'Save your changes first')
|
||||
: t('designer.publishHint', 'Makes this the live certificate design')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconRosetteDiscountCheck size={16} />}
|
||||
loading={publishing}
|
||||
disabled={!canPublish || isPublished || dirty || !selected}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => publishTemplate(selected!.id).unwrap(),
|
||||
t('designer.published', 'Design published'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.publish', 'Publish')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div style={{ flex: 1 }} />
|
||||
{selected && isPublished && canPublish && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() =>
|
||||
run(
|
||||
() => archiveTemplate(selected.id).unwrap(),
|
||||
t('designer.archived', 'Design withdrawn'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.archive', 'Withdraw')}
|
||||
</Button>
|
||||
)}
|
||||
{selected && !isPublished && canEdit && (
|
||||
<Tooltip label={t('designer.delete', 'Delete draft')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t('designer.delete', 'Delete draft')}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => deleteTemplate(selected.id).unwrap(),
|
||||
t('designer.deleted', 'Draft deleted'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Placeholders */}
|
||||
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.variables', 'Placeholders')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('designer.variablesHint', 'Click to insert at the cursor.')}
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={480} type="hover">
|
||||
<Stack gap={4}>
|
||||
{variables.map((variable) => (
|
||||
<Tooltip key={variable.key} label={variable.label} position="left">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
justify="flex-start"
|
||||
disabled={!canEdit || isPublished}
|
||||
onClick={() => insertVariable(variable.key)}
|
||||
>
|
||||
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={newOpen}
|
||||
onClose={() => setNewOpen(false)}
|
||||
title={t('designer.newVersion', 'New version')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
||||
withAsterisk
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'designer.newHint',
|
||||
'Starts from the live design, or the built-in layout if this type has none.',
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setNewOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={creating}
|
||||
disabled={!newName.trim()}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
setSelectedId(created.id);
|
||||
setNewOpen(false);
|
||||
}, t('designer.created', 'Draft created'))
|
||||
}
|
||||
>
|
||||
{t('designer.create', 'Create')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CertificateDesignerPage;
|
||||
Reference in New Issue
Block a user