mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 16:35:43 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user