diff --git a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx new file mode 100644 index 000000000..8e8684350 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx @@ -0,0 +1,191 @@ +import { + ActionIcon, + ColorInput, + Group, + NumberInput, + Paper, + SegmentedControl, + Select, + Stack, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconTrash } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api'; + +interface Props { + block: TemplateFieldPlacement | null; + variables: TemplateVariable[]; + onChange: (block: TemplateFieldPlacement) => void; + onDelete: (id: string) => void; + disabled: boolean; +} + +/** Everything about the selected block that is not its position on the page. */ +export function BlockPropertiesPanel({ + block, + variables, + onChange, + onDelete, + disabled, +}: Props) { + const { t } = useTranslation(); + + if (!block) { + return ( + + + {t('designer.noBlockSelected', 'Select a block on the page to edit it.')} + + + ); + } + + const isLiteral = block.variable === null; + + return ( + + + + + {t('designer.blockProperties', 'Selected block')} + + + onDelete(block.id)} + > + + + + + + + onChange( + value === 'text' + ? { ...block, variable: null, text: block.text ?? '' } + : { ...block, variable: variables[0]?.key ?? 'companyName' }, + ) + } + data={[ + { value: 'variable', label: t('designer.blockVariable', 'Variable') }, + { value: 'text', label: t('designer.blockText', 'Fixed text') }, + ]} + /> + + {isLiteral ? ( + onChange({ ...block, text: e.currentTarget.value })} + disabled={disabled} + /> + ) : ( + + + {/* Validity lives beside the design because it is the other half of + what a certificate promises. */} + onValidityChange(Math.round(Number(value || 0) * 12))} + min={0.5} + max={20} + step={0.5} + decimalScale={1} + w={190} + disabled={!canEdit} + /> + + + + + + +
+ + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-designer/components/NewVersionModal.tsx b/apps/backoffice/src/app/features/certificate-designer/components/NewVersionModal.tsx new file mode 100644 index 000000000..be0f59876 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/components/NewVersionModal.tsx @@ -0,0 +1,51 @@ +import { Button, Modal, Stack, Text, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ModalFooter } from '@ema-platform/ui'; + +interface Props { + opened: boolean; + name: string; + onNameChange: (value: string) => void; + creating: boolean; + onClose: () => void; + onCreate: () => void; +} + +/** Starts a draft from the live design, or the built-in layout if there is none. */ +export function NewVersionModal({ + opened, + name, + onNameChange, + creating, + onClose, + onCreate, +}: Props) { + const { t } = useTranslation(); + + return ( + + + onNameChange(e.currentTarget.value)} + withAsterisk + /> + + {t( + 'designer.newHint', + 'Starts from the live design, or the built-in layout if this type has none.', + )} + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateActionBar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateActionBar.tsx new file mode 100644 index 000000000..3c0ff1711 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateActionBar.tsx @@ -0,0 +1,101 @@ +import { ActionIcon, Button, Group, Tooltip } from '@mantine/core'; +import { + IconDeviceFloppy, + IconEye, + IconRosetteDiscountCheck, + IconTrash, +} from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; + +interface Props { + hasSource: boolean; + hasSelection: boolean; + isPublished: boolean; + dirty: boolean; + canEdit: boolean; + canPublish: boolean; + saving: boolean; + publishing: boolean; + onPreview: () => void; + onSave: () => void; + onPublish: () => void; + onArchive: () => void; + onDelete: () => void; +} + +/** Preview, save, publish, withdraw and delete for the selected version. */ +export function TemplateActionBar({ + hasSource, + hasSelection, + isPublished, + dirty, + canEdit, + canPublish, + saving, + publishing, + onPreview, + onSave, + onPublish, + onArchive, + onDelete, +}: Props) { + const { t } = useTranslation(); + + const publishHint = !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'); + + return ( + + + + + + + + +
+ {hasSelection && isPublished && canPublish && ( + + )} + {hasSelection && !isPublished && canEdit && ( + + + + + + )} + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx new file mode 100644 index 000000000..70e603189 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateBackgroundPanel.tsx @@ -0,0 +1,225 @@ +import { + Button, + Group, + Image, + NumberInput, + Paper, + SegmentedControl, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { IconPhotoUp, IconTrash } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import type { TemplateLogoCorner, TemplateLogoPlacement } from '@ema-platform/api'; + +interface Props { + backgroundUrl: string; + onBackgroundChange: (url: string) => void; + logoUrl: string; + onLogoChange: (url: string) => void; + logoPlacement: TemplateLogoPlacement; + onLogoPlacementChange: (placement: TemplateLogoPlacement) => void; + landscape: boolean; + onLandscapeChange: (landscape: boolean) => void; + disabled: boolean; +} + +const CORNERS: { value: TemplateLogoCorner; labelKey: string; fallback: string }[] = [ + { value: 'TOP_LEFT', labelKey: 'designer.cornerTopLeft', fallback: 'Top left' }, + { value: 'TOP_CENTER', labelKey: 'designer.cornerTopCenter', fallback: 'Top centre' }, + { value: 'TOP_RIGHT', labelKey: 'designer.cornerTopRight', fallback: 'Top right' }, + { value: 'BOTTOM_LEFT', labelKey: 'designer.cornerBottomLeft', fallback: 'Bottom left' }, + { value: 'BOTTOM_RIGHT', labelKey: 'designer.cornerBottomRight', fallback: 'Bottom right' }, +]; + +/** + * Page-level settings: orientation, the artwork a certificate is printed on, + * and the authority's logo. + * + * Orientation belongs here rather than beside the version name because it is a + * property of the page, and it decides the shape of the canvas everything else + * is positioned on. + */ +export function TemplateBackgroundPanel({ + backgroundUrl, + onBackgroundChange, + logoUrl, + onLogoChange, + logoPlacement, + onLogoPlacementChange, + landscape, + onLandscapeChange, + disabled, +}: Props) { + const { t } = useTranslation(); + + return ( + + +
+ + {t('designer.pageSetup', 'Page setup')} + + + {t( + 'designer.pageSetupHint', + 'Orientation, background artwork and the authority logo. Certificate data is drawn on top, so the same page serves every certificate.', + )} + +
+ + +
+ + {t('designer.orientation', 'Orientation')} + + onLandscapeChange(value === 'landscape')} + disabled={disabled} + data={[ + { + value: 'portrait', + label: t('designer.portrait', 'Portrait'), + }, + { + value: 'landscape', + label: t('designer.landscape', 'Landscape'), + }, + ]} + /> + + {landscape + ? t('designer.a4Landscape', 'A4 — 297 × 210 mm') + : t('designer.a4Portrait', 'A4 — 210 × 297 mm')} + +
+
+ + + onBackgroundChange(e.currentTarget.value)} + disabled={disabled} + style={{ flex: 1 }} + /> + {backgroundUrl && ( + + )} + + + {backgroundUrl ? ( + {t('designer.backgroundPreview', + ) : ( + + + + + {t('designer.noBackground', 'No artwork — the design renders as HTML only.')} + + + + )} + + + onLogoChange(e.currentTarget.value)} + disabled={disabled} + style={{ flex: 1 }} + /> + {logoUrl && ( + + )} + + + {logoUrl && ( + +