feat: add ExamStageActions component for exam fee handling

- Implemented ExamStageActions component to manage actions related to exam booking and payment based on application status.
- Added mock-base-query for development, providing a partial mock backend for various API endpoints.
- Introduced mock-data for simulating responses in the mock-base-query, covering profiles, vessels, applications, licenses, exams, and notifications.
This commit is contained in:
fitse-yotor
2026-08-15 11:51:14 +03:00
parent d8b01a2003
commit 0ac75669d4
34 changed files with 2638 additions and 472 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 } }}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}