mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'feature/seaman' of https://github.com/Tria-plc/emaui into certficate
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized, type LicenseType } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
licenseTypes: LicenseType[];
|
||||
typeId: string | null;
|
||||
onTypeChange: (id: string | null) => void;
|
||||
validityMonths: number;
|
||||
onValidityChange: (months: number) => void;
|
||||
currentValidityMonths?: number | null;
|
||||
canEdit: boolean;
|
||||
savingValidity: boolean;
|
||||
onSaveValidity: () => void;
|
||||
onNewVersion: () => void;
|
||||
}
|
||||
|
||||
/** Licence type, certificate validity, and the entry point for a new version. */
|
||||
export function DesignerToolbar({
|
||||
licenseTypes,
|
||||
typeId,
|
||||
onTypeChange,
|
||||
validityMonths,
|
||||
onValidityChange,
|
||||
currentValidityMonths,
|
||||
canEdit,
|
||||
savingValidity,
|
||||
onSaveValidity,
|
||||
onNewVersion,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
return (
|
||||
<Group align="flex-end" mb="md" gap="sm">
|
||||
<Select
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={licenseTypes.map((type) => ({
|
||||
value: type.id,
|
||||
label: localized(type.name) || type.key,
|
||||
}))}
|
||||
value={typeId}
|
||||
onChange={onTypeChange}
|
||||
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) => onValidityChange(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 === currentValidityMonths}
|
||||
onClick={onSaveValidity}
|
||||
>
|
||||
{t('designer.saveValidity', 'Save validity')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
disabled={!canEdit || !typeId}
|
||||
onClick={onNewVersion}
|
||||
>
|
||||
{t('designer.newVersion', 'New version')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Modal opened={opened} onClose={onClose} title={t('designer.newVersion', 'New version')}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(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>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={creating} disabled={!name.trim()} onClick={onCreate}>
|
||||
{t('designer.create', 'Create')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconEye size={16} />}
|
||||
onClick={onPreview}
|
||||
disabled={!hasSource}
|
||||
>
|
||||
{t('designer.preview', 'Preview PDF')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
loading={saving}
|
||||
disabled={!canEdit || isPublished || !dirty}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t('designer.save', 'Save draft')}
|
||||
</Button>
|
||||
<Tooltip label={publishHint}>
|
||||
<span>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconRosetteDiscountCheck size={16} />}
|
||||
loading={publishing}
|
||||
disabled={!canPublish || isPublished || dirty || !hasSelection}
|
||||
onClick={onPublish}
|
||||
>
|
||||
{t('designer.publish', 'Publish')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div style={{ flex: 1 }} />
|
||||
{hasSelection && isPublished && canPublish && (
|
||||
<Button variant="subtle" color="orange" onClick={onArchive}>
|
||||
{t('designer.archive', 'Withdraw')}
|
||||
</Button>
|
||||
)}
|
||||
{hasSelection && !isPublished && canEdit && (
|
||||
<Tooltip label={t('designer.delete', 'Delete draft')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t('designer.delete', 'Delete draft')}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Button, Group, Image, Paper, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { IconPhotoUp, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
backgroundUrl: string;
|
||||
onBackgroundChange: (url: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The artwork a certificate is printed on.
|
||||
*
|
||||
* A background, not the certificate itself — the number, QR code and holder's
|
||||
* name stay in the template layer above it, so one design serves every
|
||||
* certificate it issues.
|
||||
*/
|
||||
export function TemplateBackgroundPanel({
|
||||
backgroundUrl,
|
||||
onBackgroundChange,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.background', 'Background artwork')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'designer.backgroundHint',
|
||||
'Printed underneath the template. Certificate data is drawn on top, so the same artwork serves every certificate.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Group align="flex-end" gap="sm">
|
||||
<TextInput
|
||||
label={t('designer.backgroundUrl', 'Artwork URL')}
|
||||
placeholder="https://…"
|
||||
value={backgroundUrl}
|
||||
onChange={(e) => onBackgroundChange(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{backgroundUrl && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
disabled={disabled}
|
||||
onClick={() => onBackgroundChange('')}
|
||||
>
|
||||
{t('designer.removeBackground', 'Remove')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{backgroundUrl ? (
|
||||
<Image
|
||||
src={backgroundUrl}
|
||||
alt={t('designer.backgroundPreview', 'Certificate background preview')}
|
||||
radius="sm"
|
||||
fit="contain"
|
||||
mah={220}
|
||||
fallbackSrc="data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"
|
||||
/>
|
||||
) : (
|
||||
<Paper withBorder p="lg" radius="sm" bg="var(--mantine-color-gray-light)">
|
||||
<Group justify="center" gap="xs">
|
||||
<IconPhotoUp size={18} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('designer.noBackground', 'No artwork — the design renders as HTML only.')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { Group, Paper, Switch, Text, TextInput, Textarea } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onNameChange: (value: string) => void;
|
||||
source: string;
|
||||
onSourceChange: (value: string) => void;
|
||||
landscape: boolean;
|
||||
onLandscapeChange: (value: boolean) => void;
|
||||
editorRef: RefObject<HTMLTextAreaElement | null>;
|
||||
disabled: boolean;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
/** Name, orientation and the Handlebars source for the selected version. */
|
||||
export function TemplateEditor({
|
||||
name,
|
||||
onNameChange,
|
||||
source,
|
||||
onSourceChange,
|
||||
landscape,
|
||||
onLandscapeChange,
|
||||
editorRef,
|
||||
disabled,
|
||||
isPublished,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap="sm" align="flex-end">
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Switch
|
||||
label={t('designer.landscape', 'Landscape')}
|
||||
checked={landscape}
|
||||
onChange={(e) => onLandscapeChange(e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</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) => onSourceChange(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
autosize
|
||||
minRows={18}
|
||||
maxRows={30}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Variable {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
variables: Variable[];
|
||||
disabled: boolean;
|
||||
onInsert: (key: string) => void;
|
||||
}
|
||||
|
||||
/** Placeholders the template can carry, inserted at the caret. */
|
||||
export function TemplateVariableList({ variables, disabled, onInsert }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<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={disabled}
|
||||
onClick={() => onInsert(variable.key)}
|
||||
>
|
||||
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Badge, Card, Group, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { LicenseTemplate } from '@ema-platform/api';
|
||||
import { STATUS_COLOR } from '../config/designer';
|
||||
|
||||
interface Props {
|
||||
templates: LicenseTemplate[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/** The version rail — every design ever authored for this licence type. */
|
||||
export function TemplateVersionList({ templates, selectedId, onSelect }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<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={() => onSelect(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { LicenseTemplate } from '@ema-platform/api';
|
||||
|
||||
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
|
||||
export const API_BASE_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3001/api';
|
||||
|
||||
export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
PUBLISHED: 'teal',
|
||||
ARCHIVED: 'dark',
|
||||
};
|
||||
|
||||
/** Page options sent with every save and preview — A4, background printed. */
|
||||
export function pageOptionsFor(landscape: boolean) {
|
||||
return { format: 'A4' as const, landscape, printBackground: true };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Runs a designer mutation and reports the outcome once.
|
||||
*
|
||||
* Every action on this page succeeds or fails the same way, so the toast
|
||||
* handling lives here rather than being repeated at each call site.
|
||||
*/
|
||||
export function useDesignerActions() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useCallback(
|
||||
async (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),
|
||||
});
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { LicenseTemplate } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Editor state for the selected version.
|
||||
*
|
||||
* Selection defaults to the live design because that is the one staff usually
|
||||
* open, and the fields reset whenever the selection changes so an edit can
|
||||
* never leak from one version into another.
|
||||
*/
|
||||
export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [source, setSource] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [landscape, setLandscape] = useState(true);
|
||||
const [backgroundUrl, setBackgroundUrl] = useState('');
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
|
||||
[templates, selectedId],
|
||||
);
|
||||
|
||||
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);
|
||||
setBackgroundUrl(selected.backgroundUrl ?? '');
|
||||
}, [selected]);
|
||||
|
||||
const isPublished = selected?.status === 'PUBLISHED';
|
||||
const dirty =
|
||||
Boolean(selected) &&
|
||||
(source !== selected?.hbsSource ||
|
||||
name !== selected?.name ||
|
||||
landscape !== (selected?.pageOptions?.landscape ?? true) ||
|
||||
backgroundUrl !== (selected?.backgroundUrl ?? ''));
|
||||
|
||||
/** 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);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
source,
|
||||
setSource,
|
||||
name,
|
||||
setName,
|
||||
landscape,
|
||||
setLandscape,
|
||||
backgroundUrl,
|
||||
setBackgroundUrl,
|
||||
editorRef,
|
||||
isPublished,
|
||||
dirty,
|
||||
insertVariable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
import { API_BASE_URL, pageOptionsFor } from '../config/designer';
|
||||
|
||||
interface PreviewArgs {
|
||||
hbsSource: string;
|
||||
licenseTypeId: string | null;
|
||||
landscape: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useTemplatePreview() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useCallback(
|
||||
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
|
||||
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,
|
||||
licenseTypeId,
|
||||
pageOptions: pageOptionsFor(landscape),
|
||||
}),
|
||||
});
|
||||
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),
|
||||
});
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,6 @@
|
||||
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 { useEffect, useState } from 'react';
|
||||
import { Container, Group, Stack } from '@mantine/core';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -39,26 +11,23 @@ import {
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
useLocalized,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
type LicenseTemplate,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
|
||||
import { authStorage, usePermissions } from '@ema-platform/auth';
|
||||
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
|
||||
/** 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',
|
||||
};
|
||||
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
import { DesignerToolbar } from '../components/DesignerToolbar';
|
||||
import { NewVersionModal } from '../components/NewVersionModal';
|
||||
import { TemplateActionBar } from '../components/TemplateActionBar';
|
||||
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
|
||||
import { TemplateEditor } from '../components/TemplateEditor';
|
||||
import { TemplateVariableList } from '../components/TemplateVariableList';
|
||||
import { TemplateVersionList } from '../components/TemplateVersionList';
|
||||
import { pageOptionsFor } from '../config/designer';
|
||||
import { useDesignerActions } from '../hooks/useDesignerActions';
|
||||
import { useTemplateDraft } from '../hooks/useTemplateDraft';
|
||||
import { useTemplatePreview } from '../hooks/useTemplatePreview';
|
||||
|
||||
/**
|
||||
* Where the authority designs the certificate its licensees receive.
|
||||
@@ -71,7 +40,6 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
*/
|
||||
export function CertificateDesignerPage() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||
@@ -97,16 +65,15 @@ export function CertificateDesignerPage() {
|
||||
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 draft = useTemplateDraft(templates);
|
||||
const run = useDesignerActions();
|
||||
const openPreview = useTemplatePreview();
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [validityMonths, setValidityMonths] = useState<number>(12);
|
||||
|
||||
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(() => {
|
||||
@@ -117,101 +84,14 @@ export function CertificateDesignerPage() {
|
||||
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),
|
||||
});
|
||||
}
|
||||
function startNewVersion() {
|
||||
setNewName(
|
||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||
);
|
||||
setNewOpen(true);
|
||||
}
|
||||
|
||||
/** 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
const editingLocked = !canEdit || draft.isPublished;
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -223,74 +103,26 @@ export function CertificateDesignerPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Group align="flex-end" mb="md" gap="sm">
|
||||
<Select
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: localized(type.name) || 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>
|
||||
<DesignerToolbar
|
||||
licenseTypes={licenseTypes?.items ?? []}
|
||||
typeId={typeId}
|
||||
onTypeChange={(value) => {
|
||||
setTypeId(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
|
||||
@@ -308,253 +140,113 @@ export function CertificateDesignerPage() {
|
||||
)}
|
||||
action={
|
||||
canEdit
|
||||
? {
|
||||
label: t('designer.newVersion', 'New version'),
|
||||
onClick: () => {
|
||||
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
|
||||
setNewOpen(true);
|
||||
},
|
||||
}
|
||||
? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion }
|
||||
: 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>
|
||||
<TemplateVersionList
|
||||
templates={templates}
|
||||
selectedId={draft.selectedId}
|
||||
onSelect={draft.setSelectedId}
|
||||
/>
|
||||
|
||||
{/* 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 } }}
|
||||
<TemplateBackgroundPanel
|
||||
backgroundUrl={draft.backgroundUrl}
|
||||
onBackgroundChange={draft.setBackgroundUrl}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<TemplateActionBar
|
||||
hasSource={Boolean(draft.source.trim())}
|
||||
hasSelection={Boolean(draft.selected)}
|
||||
isPublished={draft.isPublished}
|
||||
dirty={draft.dirty}
|
||||
canEdit={canEdit}
|
||||
canPublish={canPublish}
|
||||
saving={saving}
|
||||
publishing={publishing}
|
||||
onPreview={() =>
|
||||
openPreview({
|
||||
hbsSource: draft.source,
|
||||
licenseTypeId: typeId,
|
||||
landscape: draft.landscape,
|
||||
})
|
||||
}
|
||||
onSave={() =>
|
||||
run(
|
||||
() =>
|
||||
updateTemplate({
|
||||
id: draft.selected!.id,
|
||||
name: draft.name,
|
||||
hbsSource: draft.source,
|
||||
pageOptions: pageOptionsFor(draft.landscape),
|
||||
backgroundUrl: draft.backgroundUrl || undefined,
|
||||
}).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>
|
||||
|
||||
{/* 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>
|
||||
<TemplateVariableList
|
||||
variables={variables}
|
||||
disabled={editingLocked}
|
||||
onInsert={draft.insertVariable}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<NewVersionModal
|
||||
opened={newOpen}
|
||||
name={newName}
|
||||
onNameChange={setNewName}
|
||||
creating={creating}
|
||||
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>
|
||||
<ModalFooter>
|
||||
<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>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
onCreate={() =>
|
||||
run(async () => {
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
draft.setSelectedId(created.id);
|
||||
setNewOpen(false);
|
||||
}, t('designer.created', 'Draft created'))
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Badge, Group, Paper, Stack, Table, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized, type LicenseType } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
licenseType: LicenseType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The STCW identity of a certificate type — regulation, level, and the
|
||||
* function and capacity rows a Certificate of Competency prints.
|
||||
*
|
||||
* Read-only for now: these are seeded configuration, and editing them safely
|
||||
* needs the approval workflow the designer guide describes. Showing them
|
||||
* matters regardless — an officer configuring fees or documents has no other
|
||||
* way to see what the certificate will actually claim.
|
||||
*/
|
||||
export function StcwMappingPanel({ licenseType }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
if (!licenseType.stcwControlled && !licenseType.certificateCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const functions = licenseType.stcwFunctions ?? [];
|
||||
const capacities = licenseType.stcwCapacities ?? [];
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{t('configuration.stcw.title', 'STCW mapping')}
|
||||
</Text>
|
||||
{licenseType.certificateCategory && (
|
||||
<Badge size="sm" variant="light">
|
||||
{licenseType.certificateCategory}
|
||||
</Badge>
|
||||
)}
|
||||
{licenseType.stcwControlled && (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{t('configuration.stcw.controlled', 'STCW controlled')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xl">
|
||||
<Field
|
||||
label={t('configuration.stcw.regulation', 'Regulation')}
|
||||
value={licenseType.stcwRegulation}
|
||||
/>
|
||||
<Field
|
||||
label={t('configuration.stcw.codeSection', 'Code section')}
|
||||
value={licenseType.stcwCodeSection}
|
||||
/>
|
||||
<Field
|
||||
label={t('configuration.stcw.department', 'Department')}
|
||||
value={licenseType.stcwDepartment}
|
||||
/>
|
||||
<Field
|
||||
label={t('configuration.stcw.level', 'Level')}
|
||||
value={licenseType.competencyLevel}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{functions.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{t('configuration.stcw.functions', 'Functions')}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('configuration.stcw.function', 'Function')}</Table.Th>
|
||||
<Table.Th>{t('configuration.stcw.level', 'Level')}</Table.Th>
|
||||
<Table.Th>
|
||||
{t('configuration.stcw.limitation', 'Limitation')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{functions.map((row, i) => (
|
||||
<Table.Tr key={`${localized(row.function)}-${i}`}>
|
||||
<Table.Td>{localized(row.function)}</Table.Td>
|
||||
<Table.Td>{row.level}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.limitation
|
||||
? localized(row.limitation)
|
||||
: t('configuration.stcw.none', 'None')}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{capacities.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{t('configuration.stcw.capacities', 'Capacities')}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('configuration.stcw.capacity', 'Capacity')}</Table.Th>
|
||||
<Table.Th>
|
||||
{t('configuration.stcw.limitation', 'Limitation')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{capacities.map((row, i) => (
|
||||
<Table.Tr key={`${localized(row.capacity)}-${i}`}>
|
||||
<Table.Td>{localized(row.capacity)}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.limitation
|
||||
? localized(row.limitation)
|
||||
: t('configuration.stcw.none', 'None')}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{licenseType.prerequisiteLicenseKeys?.length ? (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{t('configuration.stcw.prerequisites', 'Must already hold')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{licenseType.prerequisiteLicenseKeys.map((key) => (
|
||||
<Badge key={key} size="sm" variant="outline">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{value || '—'}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
applicantName: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (payload: {
|
||||
examId: string;
|
||||
admissionNumber?: string;
|
||||
examDate?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a candidate who has paid the examination fee into an existing sitting.
|
||||
*
|
||||
* Sessions are picked from the exam calendar rather than typed, because the
|
||||
* candidate joins a scheduled sitting — this is an assignment, not the creation
|
||||
* of a per-candidate appointment.
|
||||
*/
|
||||
export function ScheduleExamModal({
|
||||
opened,
|
||||
applicantName,
|
||||
loading,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened });
|
||||
const [examId, setExamId] = useState<string | null>(null);
|
||||
const [admissionNumber, setAdmissionNumber] = useState('');
|
||||
|
||||
const options = (exams?.items ?? []).map((exam) => ({
|
||||
value: exam.id,
|
||||
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
}));
|
||||
const selected = exams?.items?.find((exam) => exam.id === examId);
|
||||
|
||||
function confirm() {
|
||||
if (!examId) return;
|
||||
onConfirm({
|
||||
examId,
|
||||
admissionNumber: admissionNumber.trim() || undefined,
|
||||
examDate: selected?.date ? String(selected.date) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('review.actions.scheduleExam', 'Schedule exam')}
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('review.scheduleExam.intro', {
|
||||
defaultValue:
|
||||
'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.',
|
||||
applicant: applicantName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{!isLoading && options.length === 0 ? (
|
||||
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
|
||||
{t(
|
||||
'review.scheduleExam.noSessions',
|
||||
'No exam sessions exist yet. Create one in the Exams area first.',
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Select
|
||||
label={t('review.scheduleExam.session', 'Exam session')}
|
||||
placeholder={t('review.scheduleExam.pick', 'Choose a sitting')}
|
||||
data={options}
|
||||
value={examId}
|
||||
onChange={setExamId}
|
||||
disabled={isLoading}
|
||||
searchable
|
||||
withAsterisk
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('review.scheduleExam.admissionNumber', 'Admission number')}
|
||||
description={t(
|
||||
'review.scheduleExam.admissionHint',
|
||||
'Leave blank to let the system issue one.',
|
||||
)}
|
||||
value={admissionNumber}
|
||||
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={loading} disabled={!examId} onClick={confirm}>
|
||||
{t('review.scheduleExam.confirm', 'Schedule')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type ActionId =
|
||||
| 'final-approve'
|
||||
| 'request-adjustment'
|
||||
| 'reject'
|
||||
| 'schedule-exam'
|
||||
| 'confirm-payment'
|
||||
| 'print'
|
||||
| 'copy-link'
|
||||
@@ -190,6 +191,17 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
requiresReason: true,
|
||||
irreversible: true,
|
||||
},
|
||||
{
|
||||
id: 'schedule-exam',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.scheduleExam',
|
||||
// Only after the examination fee clears — scheduling an unpaid candidate
|
||||
// is what the EXAM_PAID gate exists to prevent.
|
||||
from: ['EXAM_PAID'],
|
||||
permissions: ['can:schedule:exam-candidate'],
|
||||
emphasis: 'filled',
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
id: 'confirm-payment',
|
||||
tier: 'primary',
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
useAssignApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleExamMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
} from '../../components/DecisionConfirmModal';
|
||||
import { ActivityRail } from '../../components/ActivityRail';
|
||||
import { DocumentsTab } from '../../components/DocumentsTab';
|
||||
import { ScheduleExamModal } from '../../components/ScheduleExamModal';
|
||||
import { computeSla } from '../../sla';
|
||||
import { reviewStaffColumns } from './columns';
|
||||
import { evaluateEligibility, presentationFor } from '../../config/license-types';
|
||||
@@ -152,6 +154,7 @@ export function LicenseReviewPage() {
|
||||
const [scheduleInspection] = useScheduleInspectionMutation();
|
||||
const [recordResult] = useRecordInspectionResultMutation();
|
||||
const [confirmPayment] = useConfirmPaymentMutation();
|
||||
const [scheduleExam, { isLoading: schedulingExam }] = useScheduleExamMutation();
|
||||
const [holdApplication] = useHoldApplicationMutation();
|
||||
const [resumeApplication] = useResumeApplicationMutation();
|
||||
const [escalateApplication] = useEscalateApplicationMutation();
|
||||
@@ -169,6 +172,7 @@ export function LicenseReviewPage() {
|
||||
const [inspectionOpen, setInspectionOpen] = useState(false);
|
||||
const [inspectionDate, setInspectionDate] = useState('');
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||
const [findings, setFindings] = useState('');
|
||||
const [checklist, setChecklist] = useState<
|
||||
Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>
|
||||
@@ -329,6 +333,11 @@ export function LicenseReviewPage() {
|
||||
case 'record-inspection':
|
||||
setResultOpen(true);
|
||||
return;
|
||||
// Needs a session picked before anything is sent, so it opens its own
|
||||
// modal rather than going through the generic confirm step.
|
||||
case 'schedule-exam':
|
||||
setScheduleExamOpen(true);
|
||||
return;
|
||||
case 'copy-link':
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
notifications.show({
|
||||
@@ -905,6 +914,30 @@ export function LicenseReviewPage() {
|
||||
onConfirm={submitDecision}
|
||||
/>
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
|
||||
loading={schedulingExam}
|
||||
onClose={() => setScheduleExamOpen(false)}
|
||||
onConfirm={async (payload) => {
|
||||
try {
|
||||
await scheduleExam({ id, ...payload }).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('review.done.scheduleExam', 'Exam scheduled'),
|
||||
message: '',
|
||||
});
|
||||
setScheduleExamOpen(false);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('review.actionFailed', 'Action failed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={inspectionOpen}
|
||||
onClose={() => setInspectionOpen(false)}
|
||||
|
||||
@@ -933,6 +933,7 @@ export const am: Translations = {
|
||||
finalApprove: "አጽድቅ እና ስጥ",
|
||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||
reject: "አትቀበል",
|
||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||
confirmPayment: "ክፍያ አረጋግጥ",
|
||||
print: "ሰነድ አትም",
|
||||
copyLink: "አገናኝ ቅዳ",
|
||||
@@ -975,6 +976,7 @@ export const am: Translations = {
|
||||
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
|
||||
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
||||
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
||||
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
|
||||
},
|
||||
notifications: {
|
||||
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",
|
||||
|
||||
@@ -934,6 +934,7 @@ export const en = {
|
||||
finalApprove: 'Approve & issue',
|
||||
requestAdjustment: 'Request adjustment',
|
||||
reject: 'Reject',
|
||||
scheduleExam: 'Schedule exam',
|
||||
confirmPayment: 'Confirm payment',
|
||||
print: 'Print dossier',
|
||||
copyLink: 'Copy link',
|
||||
@@ -975,6 +976,7 @@ export const en = {
|
||||
resume: 'Returns application {{number}} to the stage it was held from.',
|
||||
escalate: 'Raises application {{number}} to a supervisor for a decision.',
|
||||
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
||||
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
|
||||
},
|
||||
notifications: {
|
||||
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
||||
|
||||
@@ -35,7 +35,16 @@ export function BackofficeLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { can } = usePermissions();
|
||||
const { permissions: granted, known } = usePermissions();
|
||||
|
||||
// TEMPORARY diagnostic — remove once the sidebar is confirmed working.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'[NAV] known=', known,
|
||||
'granted=', granted.length,
|
||||
'| cookies:', document.cookie.split('; ').map((c) => c.split('=')[0]).filter((n) => n.includes('token')),
|
||||
'| token tail:', (document.cookie.match(/ema-backoffice-auth-token=([^;]+)/)?.[1] ?? 'NONE').slice(-12),
|
||||
);
|
||||
|
||||
// Badges reflect real pending work. One grouped request on a timer, shared
|
||||
// by the sidebar and the top bar via the RTK cache.
|
||||
@@ -53,17 +62,10 @@ export function BackofficeLayout() {
|
||||
: item,
|
||||
),
|
||||
}));
|
||||
return filterByPermissions(
|
||||
withBadges,
|
||||
// `can` already fails open when the token carries no permission claim,
|
||||
// so this only ever removes items we are sure the user cannot use.
|
||||
withBadges
|
||||
.flatMap((section) => section.items)
|
||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||
.flatMap((item) => item.permissions ?? [])
|
||||
.filter((permission) => can([permission])),
|
||||
);
|
||||
}, [counts?.unassigned, can]);
|
||||
// Unfiltered until the grant list has loaded, matching PortalLayout and
|
||||
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
||||
return known ? filterByPermissions(withBadges, granted) : withBadges;
|
||||
}, [counts?.unassigned, granted, known]);
|
||||
|
||||
/** Flat list used for breadcrumbs and active-route lookup. */
|
||||
const navItems = useMemo<NavItem[]>(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi, configureTokenRefresh } from '@ema-platform/api';
|
||||
import {
|
||||
baseApi,
|
||||
configureSessionScope,
|
||||
configureTokenRefresh,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
authReducer,
|
||||
signupReducer,
|
||||
@@ -13,6 +17,9 @@ import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
||||
import { preferencesReducer } from './preferences.slice';
|
||||
|
||||
configureAuthStorage('ema-backoffice', true);
|
||||
// Cookies are shared across ports on localhost, so the API layer must be told
|
||||
// which app it belongs to — otherwise it reads the portal's token.
|
||||
configureSessionScope('ema-backoffice');
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
|
||||
@@ -4,6 +4,10 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
// Env lives at the workspace root, shared with the portal — without this
|
||||
// Vite looks in apps/backoffice and VITE_BASE_API_URL silently falls back to
|
||||
// its built-in default.
|
||||
envDir: '../../',
|
||||
cacheDir: '../../node_modules/.vite/apps/backoffice',
|
||||
server: {
|
||||
port: 4201,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
app: LicenseApplication;
|
||||
t: TFunction;
|
||||
requesting: boolean;
|
||||
paying: boolean;
|
||||
onRequestExamFee: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the candidate can do while an examined certificate is in its exam leg.
|
||||
*
|
||||
* Kept apart from the general actions column because these statuses only ever
|
||||
* occur on types that examine — folding them into that column would put five
|
||||
* more branches into a cell that already reads as a chain of ternaries.
|
||||
*
|
||||
* Returns null for every other status, so the caller can render it
|
||||
* unconditionally.
|
||||
*/
|
||||
export function ExamStageActions({
|
||||
app,
|
||||
t,
|
||||
requesting,
|
||||
paying,
|
||||
onRequestExamFee,
|
||||
onPay,
|
||||
}: Props) {
|
||||
// Eligible but not yet committed to sitting, or sat and not passed: both are
|
||||
// the same decision — ask for the fee that buys a sitting.
|
||||
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
|
||||
const retake = app.status === 'EXAM_FAILED';
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color={retake ? 'orange' : 'teal'}
|
||||
loading={requesting}
|
||||
onClick={() => onRequestExamFee(app)}
|
||||
>
|
||||
{retake
|
||||
? t('applications.actions.bookRetake', 'Book a resit')
|
||||
: t('applications.actions.bookExam', 'Book exam')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (app.status === 'EXAM_PAYMENT_PENDING') {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="yellow"
|
||||
loading={paying}
|
||||
onClick={() => onPay(app)}
|
||||
>
|
||||
{t('applications.actions.payExamFee', {
|
||||
defaultValue: 'Pay exam fee ({{amount}} {{currency}})',
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Paid and scheduled are both waiting states — nothing for the candidate to
|
||||
// do, so say so rather than offering a button that does nothing.
|
||||
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {
|
||||
return (
|
||||
<Button size="xs" variant="subtle" disabled>
|
||||
{app.status === 'EXAM_PAID'
|
||||
? t('applications.actions.awaitingDate', 'Awaiting exam date')
|
||||
: t('applications.actions.examScheduled', 'Exam scheduled')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||
import { ExamStageActions } from '../../components/ExamStageActions';
|
||||
|
||||
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
||||
const EXAM_STAGE_STATUSES = [
|
||||
'ELIGIBILITY_APPROVED',
|
||||
'EXAM_PAYMENT_PENDING',
|
||||
'EXAM_PAID',
|
||||
'EXAM_SCHEDULED',
|
||||
'EXAM_FAILED',
|
||||
];
|
||||
|
||||
export function applicationActionsColumn(
|
||||
t: TFunction,
|
||||
@@ -13,9 +23,12 @@ export function applicationActionsColumn(
|
||||
bypassEnabled: boolean;
|
||||
bypassing: boolean;
|
||||
isPaying: boolean;
|
||||
/** True while the exam fee is being raised for a booking or a resit. */
|
||||
requestingExamFee: boolean;
|
||||
onBypass: (app: LicenseApplication) => void;
|
||||
onCertificate: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
onRequestExamFee: (app: LicenseApplication) => void;
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
@@ -27,7 +40,20 @@ export function applicationActionsColumn(
|
||||
const app = row.original;
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{deps.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
|
||||
{/* Renders only during the exam leg; null everywhere else. */}
|
||||
<ExamStageActions
|
||||
app={app}
|
||||
t={t}
|
||||
requesting={deps.requestingExamFee}
|
||||
paying={deps.isPaying}
|
||||
onRequestExamFee={deps.onRequestExamFee}
|
||||
onPay={deps.onPay}
|
||||
/>
|
||||
{/* Both fee stops are bypassable — an examined certificate is
|
||||
otherwise untestable without a live gateway. */}
|
||||
{deps.bypassEnabled &&
|
||||
(app.status === 'PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PAYMENT_PENDING') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
@@ -56,8 +82,11 @@ export function applicationActionsColumn(
|
||||
</Button>
|
||||
)}
|
||||
{/* In PAYMENT_PENDING this button initiates payment, so it needs
|
||||
that grant; every other status it merely opens the wizard. */}
|
||||
{(app.status !== 'PAYMENT_PENDING' ||
|
||||
that grant; every other status it merely opens the wizard.
|
||||
Suppressed during the exam leg, where ExamStageActions already
|
||||
supplies the action that matters. */}
|
||||
{!EXAM_STAGE_STATUSES.includes(app.status) &&
|
||||
(app.status !== 'PAYMENT_PENDING' ||
|
||||
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
|
||||
<Button
|
||||
size="xs"
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useRequestExamPaymentMutation,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
@@ -98,6 +99,8 @@ export function MyApplicationsPage() {
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [requestExamPayment, { isLoading: requestingExamFee }] =
|
||||
useRequestExamPaymentMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||
@@ -131,6 +134,33 @@ export function MyApplicationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the examination fee, for a first sitting or a resit.
|
||||
*
|
||||
* Payment is a separate step: this only moves the application to
|
||||
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
|
||||
* the provider the same way every other fee does.
|
||||
*/
|
||||
async function requestExamFee(applicationId: string) {
|
||||
try {
|
||||
await requestExamPayment(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
||||
message: t(
|
||||
'applications.examFeeRequestedBody',
|
||||
'Pay the examination fee and you will be scheduled for a sitting.',
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('applications.examFeeFailed', 'Could not book the exam'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the certificate belonging to an application.
|
||||
*
|
||||
@@ -247,9 +277,11 @@ export function MyApplicationsPage() {
|
||||
bypassEnabled: capabilities?.bypassEnabled ?? false,
|
||||
bypassing,
|
||||
isPaying,
|
||||
requestingExamFee,
|
||||
onBypass: (app) => handleBypass(app.id),
|
||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||
onPay: (app) => pay(app.id),
|
||||
onRequestExamFee: (app) => requestExamFee(app.id),
|
||||
onOpen: (app) =>
|
||||
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { baseApi, configureTokenRefresh } from "@ema-platform/api";
|
||||
import {
|
||||
baseApi,
|
||||
configureSessionScope,
|
||||
configureTokenRefresh,
|
||||
} from "@ema-platform/api";
|
||||
import {
|
||||
authReducer,
|
||||
signupReducer,
|
||||
@@ -12,6 +16,35 @@ import {
|
||||
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
|
||||
|
||||
configureAuthStorage("ema-portal", true);
|
||||
// See the backoffice store: cookies ignore the port, so the API layer is told
|
||||
// which app it is rather than guessing from a shared jar.
|
||||
configureSessionScope("ema-portal");
|
||||
|
||||
// Dev-only preview mode (VITE_USE_MOCKS=true): seed a fake session so
|
||||
// ProtectedRoute (which only checks that a token exists) treats the user as
|
||||
// logged in without a real backend to authenticate against. Only runs when
|
||||
// no real session is already present, so a genuine login is never clobbered.
|
||||
if (
|
||||
(import.meta as { env?: Record<string, string> }).env?.["VITE_USE_MOCKS"] === "true" &&
|
||||
!authStorage.getToken()
|
||||
) {
|
||||
authStorage.setToken("mock-dev-token");
|
||||
authStorage.setRefreshToken("mock-dev-refresh-token");
|
||||
authStorage.setUser<AuthUser>({
|
||||
id: "user-mock-001",
|
||||
email: "abebe.tesfaye@example.et",
|
||||
username: "abebe.tesfaye",
|
||||
phoneNumber: "+251911223344",
|
||||
name: { am: "አበበ ተስፋዬ", en: "Abebe Tesfaye" },
|
||||
status: "ACTIVE",
|
||||
sharepointId: null,
|
||||
hasSetPassword: true,
|
||||
hasFinishedRegistration: true,
|
||||
hasFinishedDMSOnboarding: true,
|
||||
isPhoneNumberVerified: true,
|
||||
userType: "PORTAL",
|
||||
});
|
||||
}
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
|
||||
@@ -4,6 +4,10 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
// Env lives at the workspace root, shared with the backoffice — without this
|
||||
// Vite looks in apps/portal and VITE_BASE_API_URL silently falls back to its
|
||||
// built-in default.
|
||||
envDir: '../../',
|
||||
cacheDir: '../../node_modules/.vite/apps/portal',
|
||||
server: { port: 4200, host: 'localhost' },
|
||||
preview: { port: 4200, host: 'localhost' },
|
||||
|
||||
Reference in New Issue
Block a user