mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useEffect, useState } from 'react';
|
||||||
import {
|
import { Container, Group, Stack } from '@mantine/core';
|
||||||
ActionIcon,
|
import { IconAlertCircle } from '@tabler/icons-react';
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Code,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
NumberInput,
|
|
||||||
Paper,
|
|
||||||
ScrollArea,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconAlertCircle,
|
|
||||||
IconDeviceFloppy,
|
|
||||||
IconEye,
|
|
||||||
IconPlus,
|
|
||||||
IconRosetteDiscountCheck,
|
|
||||||
IconTrash,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
@@ -39,26 +11,23 @@ import {
|
|||||||
useGetLicenseTemplatesQuery,
|
useGetLicenseTemplatesQuery,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetTemplateVariablesQuery,
|
useGetTemplateVariablesQuery,
|
||||||
useLocalized,
|
|
||||||
usePublishLicenseTemplateMutation,
|
usePublishLicenseTemplateMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
useUpdateLicenseTemplateMutation,
|
useUpdateLicenseTemplateMutation,
|
||||||
type LicenseTemplate,
|
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
|
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
||||||
import { authStorage, usePermissions } from '@ema-platform/auth';
|
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||||
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
import { DesignerToolbar } from '../components/DesignerToolbar';
|
||||||
|
import { NewVersionModal } from '../components/NewVersionModal';
|
||||||
/** Same resolution the shared RTK Query baseQuery uses. */
|
import { TemplateActionBar } from '../components/TemplateActionBar';
|
||||||
const API_BASE_URL =
|
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
|
||||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
import { TemplateEditor } from '../components/TemplateEditor';
|
||||||
'http://localhost:3000/api';
|
import { TemplateVariableList } from '../components/TemplateVariableList';
|
||||||
|
import { TemplateVersionList } from '../components/TemplateVersionList';
|
||||||
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
import { pageOptionsFor } from '../config/designer';
|
||||||
DRAFT: 'gray',
|
import { useDesignerActions } from '../hooks/useDesignerActions';
|
||||||
PUBLISHED: 'teal',
|
import { useTemplateDraft } from '../hooks/useTemplateDraft';
|
||||||
ARCHIVED: 'dark',
|
import { useTemplatePreview } from '../hooks/useTemplatePreview';
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the authority designs the certificate its licensees receive.
|
* Where the authority designs the certificate its licensees receive.
|
||||||
@@ -71,7 +40,6 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
|||||||
*/
|
*/
|
||||||
export function CertificateDesignerPage() {
|
export function CertificateDesignerPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const localized = useLocalized();
|
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||||
@@ -97,16 +65,15 @@ export function CertificateDesignerPage() {
|
|||||||
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
|
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
|
||||||
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
|
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const draft = useTemplateDraft(templates);
|
||||||
const [source, setSource] = useState('');
|
const run = useDesignerActions();
|
||||||
const [name, setName] = useState('');
|
const openPreview = useTemplatePreview();
|
||||||
const [landscape, setLandscape] = useState(true);
|
|
||||||
const [newOpen, setNewOpen] = useState(false);
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
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 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.
|
// Default to the first licence type so the page is never an empty shell.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,101 +84,14 @@ export function CertificateDesignerPage() {
|
|||||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||||
}, [selectedType]);
|
}, [selectedType]);
|
||||||
|
|
||||||
const selected = useMemo(
|
function startNewVersion() {
|
||||||
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
|
setNewName(
|
||||||
[templates, selectedId],
|
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||||
);
|
);
|
||||||
|
setNewOpen(true);
|
||||||
// Pick the live design by default — that is the one staff usually want.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!templates.length) {
|
|
||||||
setSelectedId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
|
|
||||||
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
|
|
||||||
setSelectedId((published ?? templates[0]).id);
|
|
||||||
}, [templates, selectedId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) return;
|
|
||||||
setSource(selected.hbsSource);
|
|
||||||
setName(selected.name);
|
|
||||||
setLandscape(selected.pageOptions?.landscape ?? true);
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const isPublished = selected?.status === 'PUBLISHED';
|
|
||||||
const dirty =
|
|
||||||
Boolean(selected) &&
|
|
||||||
(source !== selected?.hbsSource ||
|
|
||||||
name !== selected?.name ||
|
|
||||||
landscape !== (selected?.pageOptions?.landscape ?? true));
|
|
||||||
|
|
||||||
async function run(action: () => Promise<unknown>, success: string) {
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
notifications.show({ color: 'teal', title: success, message: '' });
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
color: 'red',
|
|
||||||
title: t('designer.actionFailed', 'Action failed'),
|
|
||||||
message: extractErrorMessage(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inserts a placeholder where the caret is, rather than at the end. */
|
const editingLocked = !canEdit || draft.isPublished;
|
||||||
function insertVariable(key: string) {
|
|
||||||
const el = editorRef.current;
|
|
||||||
const token = `{{${key}}}`;
|
|
||||||
if (!el) {
|
|
||||||
setSource((prev) => prev + token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const start = el.selectionStart ?? source.length;
|
|
||||||
const end = el.selectionEnd ?? start;
|
|
||||||
setSource(source.slice(0, start) + token + source.slice(end));
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.focus();
|
|
||||||
el.setSelectionRange(start + token.length, start + token.length);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the editor's current contents, not the saved row, so unsaved edits
|
|
||||||
* are what you see. Opened as a blob so it never leaves a file behind.
|
|
||||||
*/
|
|
||||||
async function preview() {
|
|
||||||
try {
|
|
||||||
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
|
|
||||||
// and calls the API directly — which means spelling out the base URL and
|
|
||||||
// the bearer token that the shared baseQuery would normally attach.
|
|
||||||
const token = authStorage.getToken();
|
|
||||||
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
hbsSource: source,
|
|
||||||
licenseTypeId: typeId,
|
|
||||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(await response.text());
|
|
||||||
const url = URL.createObjectURL(await response.blob());
|
|
||||||
window.open(url, '_blank', 'noopener');
|
|
||||||
// Give the new tab time to read it before revoking.
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
color: 'red',
|
|
||||||
title: t('designer.previewFailed', 'Could not render the preview'),
|
|
||||||
message: extractErrorMessage(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
@@ -223,74 +103,26 @@ export function CertificateDesignerPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group align="flex-end" mb="md" gap="sm">
|
<DesignerToolbar
|
||||||
<Select
|
licenseTypes={licenseTypes?.items ?? []}
|
||||||
label={t('designer.licenceType', 'Licence type')}
|
typeId={typeId}
|
||||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
onTypeChange={(value) => {
|
||||||
value: type.id,
|
setTypeId(value);
|
||||||
label: localized(type.name) || type.key,
|
draft.setSelectedId(null);
|
||||||
}))}
|
}}
|
||||||
value={typeId}
|
validityMonths={validityMonths}
|
||||||
onChange={(value) => {
|
onValidityChange={setValidityMonths}
|
||||||
setTypeId(value);
|
currentValidityMonths={selectedType?.validityMonths}
|
||||||
setSelectedId(null);
|
canEdit={canEdit}
|
||||||
}}
|
savingValidity={savingValidity}
|
||||||
w={280}
|
onSaveValidity={() =>
|
||||||
/>
|
run(
|
||||||
|
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||||
{/* Validity lives beside the design because it is the other half of
|
t('designer.validitySaved', 'Validity updated'),
|
||||||
what a certificate promises. */}
|
)
|
||||||
<NumberInput
|
}
|
||||||
label={t('designer.validityYears', 'Valid for (years)')}
|
onNewVersion={startNewVersion}
|
||||||
description={t('designer.validityHint', 'Applied when a licence is issued')}
|
/>
|
||||||
value={Number((validityMonths / 12).toFixed(2))}
|
|
||||||
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
|
|
||||||
min={0.5}
|
|
||||||
max={20}
|
|
||||||
step={0.5}
|
|
||||||
decimalScale={1}
|
|
||||||
w={190}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
label={
|
|
||||||
canEdit
|
|
||||||
? t('designer.saveValidity', 'Save validity')
|
|
||||||
: t('designer.noPermission', 'You do not have permission')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
loading={savingValidity}
|
|
||||||
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
|
|
||||||
onClick={() =>
|
|
||||||
run(
|
|
||||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
|
||||||
t('designer.validitySaved', 'Validity updated'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('designer.saveValidity', 'Save validity')}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
leftSection={<IconPlus size={16} />}
|
|
||||||
disabled={!canEdit || !typeId}
|
|
||||||
onClick={() => {
|
|
||||||
setNewName(
|
|
||||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
|
||||||
);
|
|
||||||
setNewOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('designer.newVersion', 'New version')}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
@@ -308,253 +140,113 @@ export function CertificateDesignerPage() {
|
|||||||
)}
|
)}
|
||||||
action={
|
action={
|
||||||
canEdit
|
canEdit
|
||||||
? {
|
? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion }
|
||||||
label: t('designer.newVersion', 'New version'),
|
|
||||||
onClick: () => {
|
|
||||||
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
|
|
||||||
setNewOpen(true);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Group align="flex-start" gap="md" wrap="nowrap">
|
<Group align="flex-start" gap="md" wrap="nowrap">
|
||||||
{/* Versions */}
|
<TemplateVersionList
|
||||||
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
|
templates={templates}
|
||||||
<Text fw={600} size="sm">
|
selectedId={draft.selectedId}
|
||||||
{t('designer.versions', 'Versions')}
|
onSelect={draft.setSelectedId}
|
||||||
</Text>
|
/>
|
||||||
{templates.map((tpl) => (
|
|
||||||
<Card
|
|
||||||
key={tpl.id}
|
|
||||||
withBorder
|
|
||||||
padding="xs"
|
|
||||||
onClick={() => setSelectedId(tpl.id)}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderColor:
|
|
||||||
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<Text size="sm" fw={500} truncate>
|
|
||||||
{tpl.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
v{tpl.version}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
|
|
||||||
{tpl.status}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Editor */}
|
|
||||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Group gap="sm" align="flex-end">
|
<TemplateBackgroundPanel
|
||||||
<TextInput
|
backgroundUrl={draft.backgroundUrl}
|
||||||
label={t('designer.name', 'Version name')}
|
onBackgroundChange={draft.setBackgroundUrl}
|
||||||
value={name}
|
disabled={editingLocked}
|
||||||
onChange={(e) => setName(e.currentTarget.value)}
|
|
||||||
disabled={!canEdit || isPublished}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label={t('designer.landscape', 'Landscape')}
|
|
||||||
checked={landscape}
|
|
||||||
onChange={(e) => setLandscape(e.currentTarget.checked)}
|
|
||||||
disabled={!canEdit || isPublished}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{isPublished && (
|
|
||||||
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
|
|
||||||
<Text size="xs">
|
|
||||||
{t(
|
|
||||||
'designer.publishedLocked',
|
|
||||||
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
ref={editorRef}
|
|
||||||
label={t('designer.source', 'Template (Handlebars + HTML)')}
|
|
||||||
value={source}
|
|
||||||
onChange={(e) => setSource(e.currentTarget.value)}
|
|
||||||
disabled={!canEdit || isPublished}
|
|
||||||
autosize
|
|
||||||
minRows={18}
|
|
||||||
maxRows={30}
|
|
||||||
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group>
|
<TemplateEditor
|
||||||
<Button
|
name={draft.name}
|
||||||
variant="light"
|
onNameChange={draft.setName}
|
||||||
leftSection={<IconEye size={16} />}
|
source={draft.source}
|
||||||
onClick={preview}
|
onSourceChange={draft.setSource}
|
||||||
disabled={!source.trim()}
|
landscape={draft.landscape}
|
||||||
>
|
onLandscapeChange={draft.setLandscape}
|
||||||
{t('designer.preview', 'Preview PDF')}
|
editorRef={draft.editorRef}
|
||||||
</Button>
|
disabled={editingLocked}
|
||||||
<Button
|
isPublished={draft.isPublished}
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
/>
|
||||||
loading={saving}
|
|
||||||
disabled={!canEdit || isPublished || !dirty}
|
<TemplateActionBar
|
||||||
onClick={() =>
|
hasSource={Boolean(draft.source.trim())}
|
||||||
run(
|
hasSelection={Boolean(draft.selected)}
|
||||||
() =>
|
isPublished={draft.isPublished}
|
||||||
updateTemplate({
|
dirty={draft.dirty}
|
||||||
id: selected!.id,
|
canEdit={canEdit}
|
||||||
name,
|
canPublish={canPublish}
|
||||||
hbsSource: source,
|
saving={saving}
|
||||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
publishing={publishing}
|
||||||
}).unwrap(),
|
onPreview={() =>
|
||||||
t('designer.saved', 'Draft saved'),
|
openPreview({
|
||||||
)
|
hbsSource: draft.source,
|
||||||
}
|
licenseTypeId: typeId,
|
||||||
>
|
landscape: draft.landscape,
|
||||||
{t('designer.save', 'Save draft')}
|
})
|
||||||
</Button>
|
}
|
||||||
<Tooltip
|
onSave={() =>
|
||||||
label={
|
run(
|
||||||
!canPublish
|
() =>
|
||||||
? t('designer.noPublishPermission', 'You cannot publish designs')
|
updateTemplate({
|
||||||
: dirty
|
id: draft.selected!.id,
|
||||||
? t('designer.saveFirst', 'Save your changes first')
|
name: draft.name,
|
||||||
: t('designer.publishHint', 'Makes this the live certificate design')
|
hbsSource: draft.source,
|
||||||
}
|
pageOptions: pageOptionsFor(draft.landscape),
|
||||||
>
|
backgroundUrl: draft.backgroundUrl || undefined,
|
||||||
<span>
|
}).unwrap(),
|
||||||
<Button
|
t('designer.saved', 'Draft saved'),
|
||||||
color="teal"
|
)
|
||||||
leftSection={<IconRosetteDiscountCheck size={16} />}
|
}
|
||||||
loading={publishing}
|
onPublish={() =>
|
||||||
disabled={!canPublish || isPublished || dirty || !selected}
|
run(
|
||||||
onClick={() =>
|
() => publishTemplate(draft.selected!.id).unwrap(),
|
||||||
run(
|
t('designer.published', 'Design published'),
|
||||||
() => publishTemplate(selected!.id).unwrap(),
|
)
|
||||||
t('designer.published', 'Design published'),
|
}
|
||||||
)
|
onArchive={() =>
|
||||||
}
|
run(
|
||||||
>
|
() => archiveTemplate(draft.selected!.id).unwrap(),
|
||||||
{t('designer.publish', 'Publish')}
|
t('designer.archived', 'Design withdrawn'),
|
||||||
</Button>
|
)
|
||||||
</span>
|
}
|
||||||
</Tooltip>
|
onDelete={() =>
|
||||||
<div style={{ flex: 1 }} />
|
run(
|
||||||
{selected && isPublished && canPublish && (
|
() => deleteTemplate(draft.selected!.id).unwrap(),
|
||||||
<Button
|
t('designer.deleted', 'Draft deleted'),
|
||||||
variant="subtle"
|
)
|
||||||
color="orange"
|
}
|
||||||
onClick={() =>
|
/>
|
||||||
run(
|
|
||||||
() => archiveTemplate(selected.id).unwrap(),
|
|
||||||
t('designer.archived', 'Design withdrawn'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('designer.archive', 'Withdraw')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{selected && !isPublished && canEdit && (
|
|
||||||
<Tooltip label={t('designer.delete', 'Delete draft')}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
aria-label={t('designer.delete', 'Delete draft')}
|
|
||||||
onClick={() =>
|
|
||||||
run(
|
|
||||||
() => deleteTemplate(selected.id).unwrap(),
|
|
||||||
t('designer.deleted', 'Draft deleted'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Placeholders */}
|
<TemplateVariableList
|
||||||
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
|
variables={variables}
|
||||||
<Text fw={600} size="sm">
|
disabled={editingLocked}
|
||||||
{t('designer.variables', 'Placeholders')}
|
onInsert={draft.insertVariable}
|
||||||
</Text>
|
/>
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('designer.variablesHint', 'Click to insert at the cursor.')}
|
|
||||||
</Text>
|
|
||||||
<ScrollArea.Autosize mah={480} type="hover">
|
|
||||||
<Stack gap={4}>
|
|
||||||
{variables.map((variable) => (
|
|
||||||
<Tooltip key={variable.key} label={variable.label} position="left">
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="default"
|
|
||||||
justify="flex-start"
|
|
||||||
disabled={!canEdit || isPublished}
|
|
||||||
onClick={() => insertVariable(variable.key)}
|
|
||||||
>
|
|
||||||
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<NewVersionModal
|
||||||
opened={newOpen}
|
opened={newOpen}
|
||||||
|
name={newName}
|
||||||
|
onNameChange={setNewName}
|
||||||
|
creating={creating}
|
||||||
onClose={() => setNewOpen(false)}
|
onClose={() => setNewOpen(false)}
|
||||||
title={t('designer.newVersion', 'New version')}
|
onCreate={() =>
|
||||||
>
|
run(async () => {
|
||||||
<Stack>
|
const created = await createTemplate({
|
||||||
<TextInput
|
licenseTypeId: typeId as string,
|
||||||
label={t('designer.name', 'Version name')}
|
name: newName.trim(),
|
||||||
value={newName}
|
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
}).unwrap();
|
||||||
withAsterisk
|
draft.setSelectedId(created.id);
|
||||||
/>
|
setNewOpen(false);
|
||||||
<Text size="xs" c="dimmed">
|
}, t('designer.created', 'Draft created'))
|
||||||
{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>
|
|
||||||
</Container>
|
</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'
|
| 'final-approve'
|
||||||
| 'request-adjustment'
|
| 'request-adjustment'
|
||||||
| 'reject'
|
| 'reject'
|
||||||
|
| 'schedule-exam'
|
||||||
| 'confirm-payment'
|
| 'confirm-payment'
|
||||||
| 'print'
|
| 'print'
|
||||||
| 'copy-link'
|
| 'copy-link'
|
||||||
@@ -190,6 +191,17 @@ export const ACTIONS: ActionDefinition[] = [
|
|||||||
requiresReason: true,
|
requiresReason: true,
|
||||||
irreversible: 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',
|
id: 'confirm-payment',
|
||||||
tier: 'primary',
|
tier: 'primary',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
useAssignApplicationMutation,
|
useAssignApplicationMutation,
|
||||||
useCompleteReviewMutation,
|
useCompleteReviewMutation,
|
||||||
useConfirmPaymentMutation,
|
useConfirmPaymentMutation,
|
||||||
|
useScheduleExamMutation,
|
||||||
useEscalateApplicationMutation,
|
useEscalateApplicationMutation,
|
||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useGetApplicationForReviewQuery,
|
useGetApplicationForReviewQuery,
|
||||||
@@ -76,6 +77,7 @@ import {
|
|||||||
} from '../../components/DecisionConfirmModal';
|
} from '../../components/DecisionConfirmModal';
|
||||||
import { ActivityRail } from '../../components/ActivityRail';
|
import { ActivityRail } from '../../components/ActivityRail';
|
||||||
import { DocumentsTab } from '../../components/DocumentsTab';
|
import { DocumentsTab } from '../../components/DocumentsTab';
|
||||||
|
import { ScheduleExamModal } from '../../components/ScheduleExamModal';
|
||||||
import { computeSla } from '../../sla';
|
import { computeSla } from '../../sla';
|
||||||
import { reviewStaffColumns } from './columns';
|
import { reviewStaffColumns } from './columns';
|
||||||
import { evaluateEligibility, presentationFor } from '../../config/license-types';
|
import { evaluateEligibility, presentationFor } from '../../config/license-types';
|
||||||
@@ -152,6 +154,7 @@ export function LicenseReviewPage() {
|
|||||||
const [scheduleInspection] = useScheduleInspectionMutation();
|
const [scheduleInspection] = useScheduleInspectionMutation();
|
||||||
const [recordResult] = useRecordInspectionResultMutation();
|
const [recordResult] = useRecordInspectionResultMutation();
|
||||||
const [confirmPayment] = useConfirmPaymentMutation();
|
const [confirmPayment] = useConfirmPaymentMutation();
|
||||||
|
const [scheduleExam, { isLoading: schedulingExam }] = useScheduleExamMutation();
|
||||||
const [holdApplication] = useHoldApplicationMutation();
|
const [holdApplication] = useHoldApplicationMutation();
|
||||||
const [resumeApplication] = useResumeApplicationMutation();
|
const [resumeApplication] = useResumeApplicationMutation();
|
||||||
const [escalateApplication] = useEscalateApplicationMutation();
|
const [escalateApplication] = useEscalateApplicationMutation();
|
||||||
@@ -169,6 +172,7 @@ export function LicenseReviewPage() {
|
|||||||
const [inspectionOpen, setInspectionOpen] = useState(false);
|
const [inspectionOpen, setInspectionOpen] = useState(false);
|
||||||
const [inspectionDate, setInspectionDate] = useState('');
|
const [inspectionDate, setInspectionDate] = useState('');
|
||||||
const [resultOpen, setResultOpen] = useState(false);
|
const [resultOpen, setResultOpen] = useState(false);
|
||||||
|
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||||
const [findings, setFindings] = useState('');
|
const [findings, setFindings] = useState('');
|
||||||
const [checklist, setChecklist] = useState<
|
const [checklist, setChecklist] = useState<
|
||||||
Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>
|
Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>
|
||||||
@@ -329,6 +333,11 @@ export function LicenseReviewPage() {
|
|||||||
case 'record-inspection':
|
case 'record-inspection':
|
||||||
setResultOpen(true);
|
setResultOpen(true);
|
||||||
return;
|
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':
|
case 'copy-link':
|
||||||
navigator.clipboard.writeText(window.location.href);
|
navigator.clipboard.writeText(window.location.href);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -905,6 +914,30 @@ export function LicenseReviewPage() {
|
|||||||
onConfirm={submitDecision}
|
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
|
<Modal
|
||||||
opened={inspectionOpen}
|
opened={inspectionOpen}
|
||||||
onClose={() => setInspectionOpen(false)}
|
onClose={() => setInspectionOpen(false)}
|
||||||
|
|||||||
@@ -930,6 +930,7 @@ export const am: Translations = {
|
|||||||
finalApprove: "አጽድቅ እና ስጥ",
|
finalApprove: "አጽድቅ እና ስጥ",
|
||||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||||
reject: "አትቀበል",
|
reject: "አትቀበል",
|
||||||
|
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||||
confirmPayment: "ክፍያ አረጋግጥ",
|
confirmPayment: "ክፍያ አረጋግጥ",
|
||||||
print: "ሰነድ አትም",
|
print: "ሰነድ አትም",
|
||||||
copyLink: "አገናኝ ቅዳ",
|
copyLink: "አገናኝ ቅዳ",
|
||||||
@@ -972,6 +973,7 @@ export const am: Translations = {
|
|||||||
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
|
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
|
||||||
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
||||||
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
||||||
|
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",
|
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",
|
||||||
|
|||||||
@@ -930,6 +930,7 @@ export const en = {
|
|||||||
finalApprove: 'Approve & issue',
|
finalApprove: 'Approve & issue',
|
||||||
requestAdjustment: 'Request adjustment',
|
requestAdjustment: 'Request adjustment',
|
||||||
reject: 'Reject',
|
reject: 'Reject',
|
||||||
|
scheduleExam: 'Schedule exam',
|
||||||
confirmPayment: 'Confirm payment',
|
confirmPayment: 'Confirm payment',
|
||||||
print: 'Print dossier',
|
print: 'Print dossier',
|
||||||
copyLink: 'Copy link',
|
copyLink: 'Copy link',
|
||||||
@@ -971,6 +972,7 @@ export const en = {
|
|||||||
resume: 'Returns application {{number}} to the stage it was held from.',
|
resume: 'Returns application {{number}} to the stage it was held from.',
|
||||||
escalate: 'Raises application {{number}} to a supervisor for a decision.',
|
escalate: 'Raises application {{number}} to a supervisor for a decision.',
|
||||||
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
||||||
|
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
||||||
|
|||||||
@@ -35,7 +35,16 @@ export function BackofficeLayout() {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const user = useAppSelector((state) => state.auth.user);
|
const user = useAppSelector((state) => state.auth.user);
|
||||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
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
|
// Badges reflect real pending work. One grouped request on a timer, shared
|
||||||
// by the sidebar and the top bar via the RTK cache.
|
// by the sidebar and the top bar via the RTK cache.
|
||||||
@@ -53,17 +62,10 @@ export function BackofficeLayout() {
|
|||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
return filterByPermissions(
|
// Unfiltered until the grant list has loaded, matching PortalLayout and
|
||||||
withBadges,
|
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
||||||
// `can` already fails open when the token carries no permission claim,
|
return known ? filterByPermissions(withBadges, granted) : withBadges;
|
||||||
// so this only ever removes items we are sure the user cannot use.
|
}, [counts?.unassigned, granted, known]);
|
||||||
withBadges
|
|
||||||
.flatMap((section) => section.items)
|
|
||||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
|
||||||
.flatMap((item) => item.permissions ?? [])
|
|
||||||
.filter((permission) => can([permission])),
|
|
||||||
);
|
|
||||||
}, [counts?.unassigned, can]);
|
|
||||||
|
|
||||||
/** Flat list used for breadcrumbs and active-route lookup. */
|
/** Flat list used for breadcrumbs and active-route lookup. */
|
||||||
const navItems = useMemo<NavItem[]>(
|
const navItems = useMemo<NavItem[]>(
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import { baseApi, configureTokenRefresh } from '@ema-platform/api';
|
import {
|
||||||
|
baseApi,
|
||||||
|
configureSessionScope,
|
||||||
|
configureTokenRefresh,
|
||||||
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
authReducer,
|
authReducer,
|
||||||
signupReducer,
|
signupReducer,
|
||||||
@@ -13,6 +17,9 @@ import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
|||||||
import { preferencesReducer } from './preferences.slice';
|
import { preferencesReducer } from './preferences.slice';
|
||||||
|
|
||||||
configureAuthStorage('ema-backoffice', true);
|
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 preloadedAuth = (() => {
|
||||||
const token = authStorage.getToken();
|
const token = authStorage.getToken();
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: __dirname,
|
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',
|
cacheDir: '../../node_modules/.vite/apps/backoffice',
|
||||||
server: {
|
server: {
|
||||||
port: 4201,
|
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 { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { LicenseApplication } from '@ema-platform/api';
|
import type { LicenseApplication } from '@ema-platform/api';
|
||||||
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
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(
|
export function applicationActionsColumn(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -13,9 +23,12 @@ export function applicationActionsColumn(
|
|||||||
bypassEnabled: boolean;
|
bypassEnabled: boolean;
|
||||||
bypassing: boolean;
|
bypassing: boolean;
|
||||||
isPaying: boolean;
|
isPaying: boolean;
|
||||||
|
/** True while the exam fee is being raised for a booking or a resit. */
|
||||||
|
requestingExamFee: boolean;
|
||||||
onBypass: (app: LicenseApplication) => void;
|
onBypass: (app: LicenseApplication) => void;
|
||||||
onCertificate: (app: LicenseApplication) => void;
|
onCertificate: (app: LicenseApplication) => void;
|
||||||
onPay: (app: LicenseApplication) => void;
|
onPay: (app: LicenseApplication) => void;
|
||||||
|
onRequestExamFee: (app: LicenseApplication) => void;
|
||||||
onOpen: (app: LicenseApplication) => void;
|
onOpen: (app: LicenseApplication) => void;
|
||||||
},
|
},
|
||||||
): AdvancedColumn<LicenseApplication> {
|
): AdvancedColumn<LicenseApplication> {
|
||||||
@@ -27,7 +40,20 @@ export function applicationActionsColumn(
|
|||||||
const app = row.original;
|
const app = row.original;
|
||||||
return (
|
return (
|
||||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
<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
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -56,8 +82,11 @@ export function applicationActionsColumn(
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* In PAYMENT_PENDING this button initiates payment, so it needs
|
{/* In PAYMENT_PENDING this button initiates payment, so it needs
|
||||||
that grant; every other status it merely opens the wizard. */}
|
that grant; every other status it merely opens the wizard.
|
||||||
{(app.status !== 'PAYMENT_PENDING' ||
|
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])) && (
|
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
|
useRequestExamPaymentMutation,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
@@ -98,6 +99,8 @@ export function MyApplicationsPage() {
|
|||||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||||
|
const [requestExamPayment, { isLoading: requestingExamFee }] =
|
||||||
|
useRequestExamPaymentMutation();
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
const { renewLicense, isRenewing } = useRenewLicense();
|
const { renewLicense, isRenewing } = useRenewLicense();
|
||||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
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.
|
* Opens the certificate belonging to an application.
|
||||||
*
|
*
|
||||||
@@ -247,9 +277,11 @@ export function MyApplicationsPage() {
|
|||||||
bypassEnabled: capabilities?.bypassEnabled ?? false,
|
bypassEnabled: capabilities?.bypassEnabled ?? false,
|
||||||
bypassing,
|
bypassing,
|
||||||
isPaying,
|
isPaying,
|
||||||
|
requestingExamFee,
|
||||||
onBypass: (app) => handleBypass(app.id),
|
onBypass: (app) => handleBypass(app.id),
|
||||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||||
onPay: (app) => pay(app.id),
|
onPay: (app) => pay(app.id),
|
||||||
|
onRequestExamFee: (app) => requestExamFee(app.id),
|
||||||
onOpen: (app) =>
|
onOpen: (app) =>
|
||||||
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
import { baseApi, configureTokenRefresh } from "@ema-platform/api";
|
import {
|
||||||
|
baseApi,
|
||||||
|
configureSessionScope,
|
||||||
|
configureTokenRefresh,
|
||||||
|
} from "@ema-platform/api";
|
||||||
import {
|
import {
|
||||||
authReducer,
|
authReducer,
|
||||||
signupReducer,
|
signupReducer,
|
||||||
@@ -12,6 +16,35 @@ import {
|
|||||||
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
|
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
|
||||||
|
|
||||||
configureAuthStorage("ema-portal", true);
|
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 preloadedAuth = (() => {
|
||||||
const token = authStorage.getToken();
|
const token = authStorage.getToken();
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: __dirname,
|
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',
|
cacheDir: '../../node_modules/.vite/apps/portal',
|
||||||
server: { port: 4200, host: 'localhost' },
|
server: { port: 4200, host: 'localhost' },
|
||||||
preview: { port: 4200, host: 'localhost' },
|
preview: { port: 4200, host: 'localhost' },
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { createApi } from "@reduxjs/toolkit/query/react";
|
import { createApi } from "@reduxjs/toolkit/query/react";
|
||||||
import { baseQueryWithReauth } from "./base-query-with-reauth";
|
import { baseQueryWithReauth } from "./base-query-with-reauth";
|
||||||
|
import { mockBaseQuery } from "./mock-base-query";
|
||||||
import { tagTypes } from "./tagTypes";
|
import { tagTypes } from "./tagTypes";
|
||||||
|
|
||||||
|
// Dev-only preview mode: set VITE_USE_MOCKS=true (repo-root .env.local) to
|
||||||
|
// serve the four preview feature areas from static sample data instead of a
|
||||||
|
// live backend. See mock-base-query.ts for what is and isn't covered.
|
||||||
|
const useMocks =
|
||||||
|
(import.meta as { env?: Record<string, string> }).env?.["VITE_USE_MOCKS"] === "true";
|
||||||
|
|
||||||
export const baseApi = createApi({
|
export const baseApi = createApi({
|
||||||
reducerPath: "baseApi",
|
reducerPath: "baseApi",
|
||||||
baseQuery: baseQueryWithReauth,
|
baseQuery: useMocks ? mockBaseQuery : baseQueryWithReauth,
|
||||||
tagTypes: ["Api", "backOfficeApi", "portalApi", ...tagTypes],
|
tagTypes: ["Api", "backOfficeApi", "portalApi", ...tagTypes],
|
||||||
endpoints: () => ({}),
|
endpoints: () => ({}),
|
||||||
});
|
});
|
||||||
|
|||||||
385
libs/api/src/lib/base-api/mock-base-query.ts
Normal file
385
libs/api/src/lib/base-api/mock-base-query.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import type { BaseQueryFn } from '@reduxjs/toolkit/query/react';
|
||||||
|
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||||
|
import { baseQueryWithReauth } from './base-query-with-reauth';
|
||||||
|
import {
|
||||||
|
mockApplicationDetails,
|
||||||
|
mockApplications,
|
||||||
|
mockLicenses,
|
||||||
|
mockLicenseTypeRequirements,
|
||||||
|
mockLicenseTypes,
|
||||||
|
mockMyExamAppeals,
|
||||||
|
mockMyExamRegistrations,
|
||||||
|
mockMyExamResults,
|
||||||
|
mockNotifications,
|
||||||
|
mockOpenExams,
|
||||||
|
mockProfileMeResponse,
|
||||||
|
mockVessels,
|
||||||
|
} from './mock-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only mock `BaseQueryFn`, opt-in via `VITE_USE_MOCKS=true` (see
|
||||||
|
* `base-api/index.ts`).
|
||||||
|
*
|
||||||
|
* Design choice: this is a partial mock, not a full fake backend. Each
|
||||||
|
* handler below matches a specific method + URL pattern for the four preview
|
||||||
|
* feature areas (vessel registration, seafarer registration, CoC
|
||||||
|
* certificates, exams) plus the shared profile/permissions/licensing-config
|
||||||
|
* endpoints they depend on. Anything not matched here falls through to the
|
||||||
|
* real `baseQueryWithReauth`, so login, signup, and any endpoint outside the
|
||||||
|
* four areas keep talking to a real backend if one is reachable. This was
|
||||||
|
* chosen over a full-mock/404 approach so the rest of the app (profile
|
||||||
|
* editing, payments, other license types, backoffice) is unaffected and a
|
||||||
|
* developer can extend coverage incrementally by adding more handlers below.
|
||||||
|
*
|
||||||
|
* Extend by adding another entry to `handlers`. Each handler receives the
|
||||||
|
* parsed method/path/params/body and returns the response payload (or
|
||||||
|
* `undefined` to fall through to the next handler / the real backend).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type MockRequest = {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockHandler = {
|
||||||
|
method: string;
|
||||||
|
/** Matches the URL path (query string stripped). */
|
||||||
|
pattern: RegExp;
|
||||||
|
respond: (req: MockRequest, match: RegExpMatchArray) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clone<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginated<T>(items: T[]) {
|
||||||
|
return { total: items.length, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockAppSeq = 100;
|
||||||
|
|
||||||
|
const handlers: MockHandler[] = [
|
||||||
|
// --------------------------------------------------------------- profile
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/profiles\/me$/,
|
||||||
|
respond: () => clone(mockProfileMeResponse),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- license types
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/license-types$/,
|
||||||
|
respond: () => paginated(clone(mockLicenseTypes)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/license-types\/requirements\/([\w-]+)$/,
|
||||||
|
respond: (_req, match) => {
|
||||||
|
const key = match[1];
|
||||||
|
const found = mockLicenseTypeRequirements[key];
|
||||||
|
return found ? clone(found) : undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/license-categories$/,
|
||||||
|
respond: () =>
|
||||||
|
paginated([
|
||||||
|
{ key: 'MARITIME_PERSONNEL', name: { en: 'Maritime Personnel' }, description: { en: 'Seafarer and vessel services' }, sortOrder: 1 },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/profiles\/me\/operations$/,
|
||||||
|
respond: () => ({ items: [] }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- applications
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/license-applications\/mine$/,
|
||||||
|
respond: () => paginated(clone(Object.values(mockApplications))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/license-applications$/,
|
||||||
|
respond: (req) => {
|
||||||
|
const body = (req.body ?? {}) as { licenseType?: string; kind?: string; previousLicenseId?: string };
|
||||||
|
const typeKey = body.licenseType ?? 'VESSEL_REGISTRATION';
|
||||||
|
const requirements = mockLicenseTypeRequirements[typeKey];
|
||||||
|
const id = `app-draft-${++mockAppSeq}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const application = {
|
||||||
|
id,
|
||||||
|
applicationNumber: `${requirements?.licenseType.certificatePrefix ?? 'APP'}-2026-${String(mockAppSeq).padStart(6, '0')}`,
|
||||||
|
licenseTypeId: requirements?.licenseType.id ?? 'license-type-unknown',
|
||||||
|
licenseType: requirements?.licenseType ?? { id: 'license-type-unknown', key: typeKey, name: { en: typeKey } },
|
||||||
|
applicantUserId: 'user-mock-001',
|
||||||
|
kind: body.kind ?? 'NEW',
|
||||||
|
status: 'DRAFT',
|
||||||
|
assignedOfficerId: null,
|
||||||
|
claimedAt: null,
|
||||||
|
formData: {},
|
||||||
|
companyName: null,
|
||||||
|
tradeName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
businessAddress: null,
|
||||||
|
capitalAmountDeclared: null,
|
||||||
|
capitalAmountVerified: null,
|
||||||
|
adjustmentRound: 0,
|
||||||
|
submittedAt: null,
|
||||||
|
decidedAt: null,
|
||||||
|
rejectionReason: null,
|
||||||
|
feeAmount: String(requirements?.fee ?? ''),
|
||||||
|
feeCurrency: requirements?.feeCurrency ?? 'ETB',
|
||||||
|
issuedLicenseId: null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
mockApplications[id] = application;
|
||||||
|
mockApplicationDetails[id] = {
|
||||||
|
application,
|
||||||
|
staff: [],
|
||||||
|
attachments: [],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: `hist-${id}-1`,
|
||||||
|
fromStatus: null,
|
||||||
|
toStatus: 'DRAFT',
|
||||||
|
event: 'created',
|
||||||
|
actorUserId: 'user-mock-001',
|
||||||
|
actorName: 'Abebe Tesfaye',
|
||||||
|
actorRole: 'APPLICANT',
|
||||||
|
remark: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remarks: [],
|
||||||
|
openRemarks: [],
|
||||||
|
availableEvents: [],
|
||||||
|
};
|
||||||
|
return clone(application);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/license-applications\/([\w-]+)$/,
|
||||||
|
respond: (_req, match) => {
|
||||||
|
const detail = mockApplicationDetails[match[1]];
|
||||||
|
return detail ? clone(detail) : undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/,
|
||||||
|
respond: (req, match) => {
|
||||||
|
const [, id, sectionKey] = match;
|
||||||
|
const application = mockApplications[id];
|
||||||
|
if (!application) return undefined;
|
||||||
|
const body = (req.body ?? {}) as { values?: Record<string, unknown> };
|
||||||
|
application.formData = { ...application.formData, [sectionKey]: body.values ?? {} };
|
||||||
|
return clone(application);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/license-applications\/([\w-]+)\/submit$/,
|
||||||
|
respond: (_req, match) => {
|
||||||
|
const application = mockApplications[match[1]];
|
||||||
|
if (!application) return undefined;
|
||||||
|
application.status = 'UNDER_REVIEW';
|
||||||
|
application.submittedAt = new Date().toISOString();
|
||||||
|
return clone(application);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/license-applications\/([\w-]+)\/resubmit$/,
|
||||||
|
respond: (_req, match) => {
|
||||||
|
const application = mockApplications[match[1]];
|
||||||
|
if (!application) return undefined;
|
||||||
|
application.status = 'UNDER_REVIEW';
|
||||||
|
return clone(application);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
pattern: /^\/license-applications\/([\w-]+)\/remarks\/([\w-]+)\/resolve$/,
|
||||||
|
respond: () => ({ ok: true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/license-applications\/([\w-]+)\/staff$/,
|
||||||
|
respond: (req) => {
|
||||||
|
const body = (req.body ?? {}) as { roleKey?: string; fullName?: string; position?: string; yearsOfExperience?: number };
|
||||||
|
return {
|
||||||
|
id: `staff-mock-${Date.now()}`,
|
||||||
|
roleKey: body.roleKey ?? '',
|
||||||
|
fullName: body.fullName ?? '',
|
||||||
|
position: body.position ?? null,
|
||||||
|
roleCategory: null,
|
||||||
|
yearsOfExperience: body.yearsOfExperience ?? null,
|
||||||
|
documents: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
pattern: /^\/license-applications\/([\w-]+)\/staff\/([\w-]+)$/,
|
||||||
|
respond: () => ({ ok: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- licenses
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/licenses\/mine$/,
|
||||||
|
respond: () => paginated(clone(mockLicenses)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/licenses\/([\w-]+)\/certificate$/,
|
||||||
|
respond: (_req, match) => ({
|
||||||
|
url: `https://example-cdn.ema.gov.et/certificates/${match[1]}.pdf`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ attachments
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/attachments$/,
|
||||||
|
respond: () => [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- notifications
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/notifications\/unseen$/,
|
||||||
|
respond: () => clone(mockNotifications),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/notifications$/,
|
||||||
|
respond: () => clone(mockNotifications),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
pattern: /^\/notifications\/([\w-]+)\/read$/,
|
||||||
|
respond: () => ({ ok: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- vessels
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/vessels\/mine$/,
|
||||||
|
respond: () => clone(mockVessels),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/vessels\/([\w-]+)\/incidents$/,
|
||||||
|
respond: (req, match) => ({
|
||||||
|
id: `incident-mock-${Date.now()}`,
|
||||||
|
vesselId: match[1],
|
||||||
|
occurredAt: (req.body as { occurredAt?: string })?.occurredAt ?? new Date().toISOString(),
|
||||||
|
location: (req.body as { location?: string })?.location ?? null,
|
||||||
|
description: (req.body as { description?: string })?.description ?? '',
|
||||||
|
severity: null,
|
||||||
|
reportedById: 'user-mock-001',
|
||||||
|
reportedByOfficer: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ exams
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/exams\/open$/,
|
||||||
|
respond: () => clone(mockOpenExams),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/exams\/registrations\/mine$/,
|
||||||
|
respond: () => clone(mockMyExamRegistrations),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/exams\/([\w-]+)\/register$/,
|
||||||
|
respond: () => ({ admissionNumber: `ADM-2026-${Math.floor(Math.random() * 900000 + 100000)}` }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/results\/mine$/,
|
||||||
|
respond: () => clone(mockMyExamResults),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/results\/appeals\/mine$/,
|
||||||
|
respond: () => clone(mockMyExamAppeals),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/results\/([\w-]+)\/appeal$/,
|
||||||
|
respond: () => ({ appealNumber: `APL-2026-${Math.floor(Math.random() * 900000 + 100000)}` }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------- seafarer records
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/sea-service-records\/mine$/,
|
||||||
|
respond: () => [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/medical-certificates\/mine$/,
|
||||||
|
respond: () => [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
pattern: /^\/sea-service-records\/mine\/sea-time$/,
|
||||||
|
respond: () => ({ totalDays: 620, verifiedRecords: 4 }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseArgs(args: string | FetchArgs): { method: string; path: string; params?: Record<string, unknown>; body?: unknown } {
|
||||||
|
if (typeof args === 'string') {
|
||||||
|
const [path] = args.split('?');
|
||||||
|
return { method: 'GET', path };
|
||||||
|
}
|
||||||
|
const [path] = (args.url ?? '').split('?');
|
||||||
|
return {
|
||||||
|
method: (args.method ?? 'GET').toUpperCase(),
|
||||||
|
path,
|
||||||
|
params: args.params as Record<string, unknown> | undefined,
|
||||||
|
body: args.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = async (
|
||||||
|
args,
|
||||||
|
api,
|
||||||
|
extraOptions,
|
||||||
|
) => {
|
||||||
|
const req = parseArgs(args);
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
if (handler.method !== req.method) continue;
|
||||||
|
const match = req.path.match(handler.pattern);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const data = handler.respond(req, match);
|
||||||
|
if (data === undefined) continue;
|
||||||
|
|
||||||
|
await delay(150 + Math.floor(Math.random() * 150));
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmatched — fall through to the real backend (auth/login, and any
|
||||||
|
// endpoint outside the four mocked preview areas).
|
||||||
|
return baseQueryWithReauth(args, api, extraOptions);
|
||||||
|
};
|
||||||
740
libs/api/src/lib/base-api/mock-data.ts
Normal file
740
libs/api/src/lib/base-api/mock-data.ts
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
/**
|
||||||
|
* Dev-only sample data for the mock base query (see `mock-base-query.ts`).
|
||||||
|
*
|
||||||
|
* Throwaway preview fixtures — realistic enough to demo the four portal
|
||||||
|
* feature areas (vessel registration, seafarer registration, CoC
|
||||||
|
* certificates, exams) without a live backend. Not used unless
|
||||||
|
* `VITE_USE_MOCKS=true`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ profile
|
||||||
|
|
||||||
|
export const mockPermissions: string[] = [
|
||||||
|
'can:View:own-profile',
|
||||||
|
'can:edit:own-profile',
|
||||||
|
'can:resubmit:license-application',
|
||||||
|
'can:upload:own-documents',
|
||||||
|
'can:View:own-documents',
|
||||||
|
'can:replace:own-documents',
|
||||||
|
'can:initiate:own-payment',
|
||||||
|
'can:View:own-payments',
|
||||||
|
'can:View:own-notifications',
|
||||||
|
'can:apply:seafarer-registration',
|
||||||
|
'can:add:own-sea-service',
|
||||||
|
'can:edit:own-sea-service',
|
||||||
|
'can:View:own-sea-service',
|
||||||
|
'can:upload:own-medical-certificate',
|
||||||
|
'can:View:own-medical-certificate',
|
||||||
|
'can:apply:seafarer-certificate',
|
||||||
|
'can:apply:exam',
|
||||||
|
'can:View:own-exam',
|
||||||
|
'can:View:own-certificates',
|
||||||
|
'can:apply:vessel-registration',
|
||||||
|
'can:View:own-vessels',
|
||||||
|
'can:report:own-vessel-incident',
|
||||||
|
'can:create:license-application',
|
||||||
|
'can:View:my-license-applications',
|
||||||
|
'can:update:license-application',
|
||||||
|
'can:submit:license-application',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockProfile = {
|
||||||
|
id: 'profile-mock-001',
|
||||||
|
userId: 'user-mock-001',
|
||||||
|
professionId: 'profession-mock-001',
|
||||||
|
addressId: 'address-mock-001',
|
||||||
|
type: 'INDIVIDUAL',
|
||||||
|
firstName: 'Abebe',
|
||||||
|
middleName: 'Kebede',
|
||||||
|
lastName: 'Tesfaye',
|
||||||
|
gender: 'MALE',
|
||||||
|
dob: '1990-04-12',
|
||||||
|
pob: 'Addis Ababa',
|
||||||
|
maritalStatus: 'MARRIED',
|
||||||
|
isComplete: true,
|
||||||
|
seafarerNumber: 'ET-SF-2026-00147',
|
||||||
|
seafarerStatus: 'ACTIVE',
|
||||||
|
seafarerDepartment: 'DECK',
|
||||||
|
seafarerStatusReason: null,
|
||||||
|
user: {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
id: 'address-mock-001',
|
||||||
|
idType: 'NID',
|
||||||
|
idNumber: '1234567890123',
|
||||||
|
nationality: 'Ethiopia',
|
||||||
|
regionId: 'region-addis',
|
||||||
|
cityId: 'city-addis',
|
||||||
|
subCityId: 'subcity-bole',
|
||||||
|
woredaId: 'woreda-03',
|
||||||
|
kebeleId: null,
|
||||||
|
streetAddress: 'Bole Road',
|
||||||
|
houseNumber: '14',
|
||||||
|
primaryPhoneNumber: '+251911223344',
|
||||||
|
secondaryPhoneNumber: null,
|
||||||
|
email: 'abebe.tesfaye@example.et',
|
||||||
|
website: null,
|
||||||
|
postalAddress: null,
|
||||||
|
emergencyContactName: 'Selamawit Tesfaye',
|
||||||
|
emergencyContactPhone: '+251911998877',
|
||||||
|
emergencyContactRelation: 'Spouse',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
profession: {
|
||||||
|
id: 'profession-mock-001',
|
||||||
|
departmentId: 'department-deck',
|
||||||
|
name: { en: 'Deck Officer' },
|
||||||
|
description: { en: 'Deck department officer' },
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockProfileMeResponse = {
|
||||||
|
profile: mockProfile,
|
||||||
|
completeness: 100,
|
||||||
|
missing: [] as string[],
|
||||||
|
permissions: mockPermissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ vessels
|
||||||
|
|
||||||
|
export const mockVessels = [
|
||||||
|
{
|
||||||
|
id: 'vessel-mock-001',
|
||||||
|
registrationNumber: 'ET-VES-2025-000041',
|
||||||
|
name: 'MV Abay Queen',
|
||||||
|
category: 'INLAND_WATERWAY',
|
||||||
|
vesselType: 'Passenger Ferry',
|
||||||
|
imoNumber: '9876543',
|
||||||
|
hullNumber: 'HN-4471',
|
||||||
|
flagState: 'Ethiopia',
|
||||||
|
portOfRegistry: 'Bahir Dar',
|
||||||
|
grossTonnage: 420,
|
||||||
|
passengerCapacity: 120,
|
||||||
|
lengthMeters: 38.5,
|
||||||
|
yearBuilt: 2018,
|
||||||
|
engineType: 'Diesel',
|
||||||
|
enginePowerKw: 950,
|
||||||
|
numberOfEngines: 2,
|
||||||
|
hullMaterial: 'Steel',
|
||||||
|
ownerUserId: 'user-mock-001',
|
||||||
|
ownerProfileId: 'profile-mock-001',
|
||||||
|
ownerName: 'Abebe Tesfaye',
|
||||||
|
applicationId: 'app-vessel-approved-001',
|
||||||
|
licenseId: 'license-vessel-001',
|
||||||
|
status: 'REGISTERED',
|
||||||
|
statusReason: null,
|
||||||
|
statusChangedAt: null,
|
||||||
|
registeredAt: '2025-11-03T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vessel-mock-002',
|
||||||
|
registrationNumber: 'ET-VES-2024-000019',
|
||||||
|
name: 'MV Tana Star',
|
||||||
|
category: 'INLAND_WATERWAY',
|
||||||
|
vesselType: 'Cargo Vessel',
|
||||||
|
imoNumber: '9765432',
|
||||||
|
hullNumber: 'HN-2290',
|
||||||
|
flagState: 'Ethiopia',
|
||||||
|
portOfRegistry: 'Bahir Dar',
|
||||||
|
grossTonnage: 610,
|
||||||
|
passengerCapacity: null,
|
||||||
|
lengthMeters: 45.2,
|
||||||
|
yearBuilt: 2015,
|
||||||
|
engineType: 'Diesel',
|
||||||
|
enginePowerKw: 1200,
|
||||||
|
numberOfEngines: 2,
|
||||||
|
hullMaterial: 'Steel',
|
||||||
|
ownerUserId: 'user-mock-001',
|
||||||
|
ownerProfileId: 'profile-mock-001',
|
||||||
|
ownerName: 'Abebe Tesfaye',
|
||||||
|
applicationId: 'app-vessel-approved-002',
|
||||||
|
licenseId: 'license-vessel-002',
|
||||||
|
status: 'REGISTERED',
|
||||||
|
statusReason: null,
|
||||||
|
statusChangedAt: null,
|
||||||
|
registeredAt: '2024-06-18T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- applications
|
||||||
|
|
||||||
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
|
export const mockApplications: Record<string, any> = {
|
||||||
|
'app-vessel-approved-001': {
|
||||||
|
id: 'app-vessel-approved-001',
|
||||||
|
applicationNumber: 'VES-2025-000041',
|
||||||
|
licenseTypeId: 'license-type-vessel-registration',
|
||||||
|
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||||
|
applicantUserId: 'user-mock-001',
|
||||||
|
kind: 'NEW',
|
||||||
|
status: 'CERTIFICATE_ISSUED',
|
||||||
|
assignedOfficerId: null,
|
||||||
|
claimedAt: null,
|
||||||
|
formData: { vesselDetails: { vesselName: 'MV Abay Queen' } },
|
||||||
|
companyName: null,
|
||||||
|
tradeName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
businessAddress: null,
|
||||||
|
capitalAmountDeclared: null,
|
||||||
|
capitalAmountVerified: null,
|
||||||
|
adjustmentRound: 0,
|
||||||
|
submittedAt: '2025-10-20T08:00:00.000Z',
|
||||||
|
decidedAt: '2025-11-03T09:00:00.000Z',
|
||||||
|
rejectionReason: null,
|
||||||
|
feeAmount: '3500',
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
issuedLicenseId: 'license-vessel-001',
|
||||||
|
createdAt: '2025-10-15T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
'app-vessel-approved-002': {
|
||||||
|
id: 'app-vessel-approved-002',
|
||||||
|
applicationNumber: 'VES-2024-000019',
|
||||||
|
licenseTypeId: 'license-type-vessel-registration',
|
||||||
|
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||||
|
applicantUserId: 'user-mock-001',
|
||||||
|
kind: 'NEW',
|
||||||
|
status: 'CERTIFICATE_ISSUED',
|
||||||
|
assignedOfficerId: null,
|
||||||
|
claimedAt: null,
|
||||||
|
formData: { vesselDetails: { vesselName: 'MV Tana Star' } },
|
||||||
|
companyName: null,
|
||||||
|
tradeName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
businessAddress: null,
|
||||||
|
capitalAmountDeclared: null,
|
||||||
|
capitalAmountVerified: null,
|
||||||
|
adjustmentRound: 0,
|
||||||
|
submittedAt: '2024-06-01T08:00:00.000Z',
|
||||||
|
decidedAt: '2024-06-18T09:00:00.000Z',
|
||||||
|
rejectionReason: null,
|
||||||
|
feeAmount: '3500',
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
issuedLicenseId: 'license-vessel-002',
|
||||||
|
createdAt: '2024-05-20T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
'app-vessel-pending-003': {
|
||||||
|
id: 'app-vessel-pending-003',
|
||||||
|
applicationNumber: 'VES-2026-000123',
|
||||||
|
licenseTypeId: 'license-type-vessel-registration',
|
||||||
|
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||||
|
applicantUserId: 'user-mock-001',
|
||||||
|
kind: 'NEW',
|
||||||
|
status: 'UNDER_REVIEW',
|
||||||
|
assignedOfficerId: 'officer-mock-001',
|
||||||
|
claimedAt: '2026-08-10T10:00:00.000Z',
|
||||||
|
formData: { vesselDetails: { vesselName: 'MV Zeway Pearl' } },
|
||||||
|
companyName: null,
|
||||||
|
tradeName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
businessAddress: null,
|
||||||
|
capitalAmountDeclared: null,
|
||||||
|
capitalAmountVerified: null,
|
||||||
|
adjustmentRound: 0,
|
||||||
|
submittedAt: '2026-08-05T08:00:00.000Z',
|
||||||
|
decidedAt: null,
|
||||||
|
rejectionReason: null,
|
||||||
|
feeAmount: '3500',
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
issuedLicenseId: null,
|
||||||
|
createdAt: '2026-08-01T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
'app-vessel-resubmit-004': {
|
||||||
|
id: 'app-vessel-resubmit-004',
|
||||||
|
applicationNumber: 'VES-2026-000098',
|
||||||
|
licenseTypeId: 'license-type-vessel-registration',
|
||||||
|
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||||
|
applicantUserId: 'user-mock-001',
|
||||||
|
kind: 'NEW',
|
||||||
|
status: 'RESUBMIT_REQUIRED',
|
||||||
|
assignedOfficerId: 'officer-mock-001',
|
||||||
|
claimedAt: '2026-07-20T10:00:00.000Z',
|
||||||
|
formData: { vesselDetails: { vesselName: 'MV Koka Voyager' } },
|
||||||
|
companyName: null,
|
||||||
|
tradeName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
businessAddress: null,
|
||||||
|
capitalAmountDeclared: null,
|
||||||
|
capitalAmountVerified: null,
|
||||||
|
adjustmentRound: 1,
|
||||||
|
submittedAt: '2026-07-15T08:00:00.000Z',
|
||||||
|
decidedAt: null,
|
||||||
|
rejectionReason: null,
|
||||||
|
feeAmount: '3500',
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
issuedLicenseId: null,
|
||||||
|
createdAt: '2026-07-10T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
'app-seafarer-registered-005': {
|
||||||
|
id: 'app-seafarer-registered-005',
|
||||||
|
applicationNumber: 'SEA-2025-000512',
|
||||||
|
licenseTypeId: 'license-type-seafarer-registration',
|
||||||
|
licenseType: { id: 'license-type-seafarer-registration', key: 'SEAFARER_REGISTRATION', name: { en: 'Seafarer Registration' } },
|
||||||
|
applicantUserId: 'user-mock-001',
|
||||||
|
kind: 'NEW',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
assignedOfficerId: null,
|
||||||
|
claimedAt: null,
|
||||||
|
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||||
|
companyName: null,
|
||||||
|
tradeName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
businessAddress: null,
|
||||||
|
capitalAmountDeclared: null,
|
||||||
|
capitalAmountVerified: null,
|
||||||
|
adjustmentRound: 0,
|
||||||
|
submittedAt: '2025-09-01T08:00:00.000Z',
|
||||||
|
decidedAt: '2025-09-20T08:00:00.000Z',
|
||||||
|
rejectionReason: null,
|
||||||
|
feeAmount: '500',
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
issuedLicenseId: null,
|
||||||
|
createdAt: '2025-08-25T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
'app-coc-issued-006': {
|
||||||
|
id: 'app-coc-issued-006',
|
||||||
|
applicationNumber: 'COC-2025-000321',
|
||||||
|
licenseTypeId: 'license-type-coc',
|
||||||
|
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||||
|
applicantUserId: 'user-mock-001',
|
||||||
|
kind: 'NEW',
|
||||||
|
status: 'CERTIFICATE_ISSUED',
|
||||||
|
assignedOfficerId: null,
|
||||||
|
claimedAt: null,
|
||||||
|
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||||
|
companyName: null,
|
||||||
|
tradeName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
businessAddress: null,
|
||||||
|
capitalAmountDeclared: null,
|
||||||
|
capitalAmountVerified: null,
|
||||||
|
adjustmentRound: 0,
|
||||||
|
submittedAt: '2025-12-01T08:00:00.000Z',
|
||||||
|
decidedAt: '2026-01-10T08:00:00.000Z',
|
||||||
|
rejectionReason: null,
|
||||||
|
feeAmount: '1200',
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
issuedLicenseId: 'license-coc-001',
|
||||||
|
createdAt: '2025-11-20T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
'app-coc-review-007': {
|
||||||
|
id: 'app-coc-review-007',
|
||||||
|
applicationNumber: 'COC-2026-000045',
|
||||||
|
licenseTypeId: 'license-type-coc',
|
||||||
|
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||||
|
applicantUserId: 'user-mock-001',
|
||||||
|
kind: 'RENEWAL',
|
||||||
|
status: 'ELIGIBILITY_APPROVED',
|
||||||
|
assignedOfficerId: 'officer-mock-002',
|
||||||
|
claimedAt: '2026-08-01T10:00:00.000Z',
|
||||||
|
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||||
|
companyName: null,
|
||||||
|
tradeName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
businessAddress: null,
|
||||||
|
capitalAmountDeclared: null,
|
||||||
|
capitalAmountVerified: null,
|
||||||
|
adjustmentRound: 0,
|
||||||
|
submittedAt: '2026-07-28T08:00:00.000Z',
|
||||||
|
decidedAt: null,
|
||||||
|
rejectionReason: null,
|
||||||
|
feeAmount: '1200',
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
issuedLicenseId: null,
|
||||||
|
createdAt: '2026-07-25T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockApplicationDetails: Record<string, any> = Object.fromEntries(
|
||||||
|
Object.entries(mockApplications).map(([id, application]) => [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
application,
|
||||||
|
staff: [],
|
||||||
|
attachments: [],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: `hist-${id}-1`,
|
||||||
|
fromStatus: null,
|
||||||
|
toStatus: 'DRAFT',
|
||||||
|
event: 'created',
|
||||||
|
actorUserId: 'user-mock-001',
|
||||||
|
actorName: 'Abebe Tesfaye',
|
||||||
|
actorRole: 'APPLICANT',
|
||||||
|
remark: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: application.createdAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `hist-${id}-2`,
|
||||||
|
fromStatus: 'DRAFT',
|
||||||
|
toStatus: 'SUBMITTED',
|
||||||
|
event: 'submitted',
|
||||||
|
actorUserId: 'user-mock-001',
|
||||||
|
actorName: 'Abebe Tesfaye',
|
||||||
|
actorRole: 'APPLICANT',
|
||||||
|
remark: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: application.submittedAt ?? application.createdAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remarks:
|
||||||
|
application.status === 'RESUBMIT_REQUIRED'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `remark-${id}-1`,
|
||||||
|
roundNumber: 1,
|
||||||
|
targetType: 'DOCUMENT',
|
||||||
|
targetKey: 'vesselOwnershipProof',
|
||||||
|
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
|
||||||
|
isResolved: false,
|
||||||
|
resolvedAt: null,
|
||||||
|
createdAt: application.submittedAt ?? application.createdAt,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
openRemarks:
|
||||||
|
application.status === 'RESUBMIT_REQUIRED'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `remark-${id}-1`,
|
||||||
|
roundNumber: 1,
|
||||||
|
targetType: 'DOCUMENT',
|
||||||
|
targetKey: 'vesselOwnershipProof',
|
||||||
|
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
|
||||||
|
isResolved: false,
|
||||||
|
resolvedAt: null,
|
||||||
|
createdAt: application.submittedAt ?? application.createdAt,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
availableEvents: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- licenses
|
||||||
|
|
||||||
|
export const mockLicenses = [
|
||||||
|
{
|
||||||
|
id: 'license-vessel-001',
|
||||||
|
certificateNumber: 'ET-VES-CERT-2025-041',
|
||||||
|
licenseTypeId: 'license-type-vessel-registration',
|
||||||
|
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||||
|
applicationId: 'app-vessel-approved-001',
|
||||||
|
companyName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
issueDate: '2025-11-03',
|
||||||
|
expiryDate: '2027-11-03',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
daysUntilExpiry: 445,
|
||||||
|
renewable: false,
|
||||||
|
verificationCode: 'VESQR001',
|
||||||
|
certificateFileKey: 'certs/vessel-001.pdf',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'license-vessel-002',
|
||||||
|
certificateNumber: 'ET-VES-CERT-2024-019',
|
||||||
|
licenseTypeId: 'license-type-vessel-registration',
|
||||||
|
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||||
|
applicationId: 'app-vessel-approved-002',
|
||||||
|
companyName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
issueDate: '2024-06-18',
|
||||||
|
expiryDate: '2026-06-18',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
daysUntilExpiry: 308,
|
||||||
|
renewable: true,
|
||||||
|
verificationCode: 'VESQR002',
|
||||||
|
certificateFileKey: 'certs/vessel-002.pdf',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'license-coc-001',
|
||||||
|
certificateNumber: 'ET-COC-2026-000321',
|
||||||
|
licenseTypeId: 'license-type-coc',
|
||||||
|
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||||
|
applicationId: 'app-coc-issued-006',
|
||||||
|
companyName: null,
|
||||||
|
tinNumber: null,
|
||||||
|
issueDate: '2026-01-10',
|
||||||
|
expiryDate: '2031-01-10',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
daysUntilExpiry: 1610,
|
||||||
|
renewable: false,
|
||||||
|
verificationCode: 'COCQR001',
|
||||||
|
certificateFileKey: 'certs/coc-001.pdf',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- exams
|
||||||
|
|
||||||
|
export const mockOpenExams = [
|
||||||
|
{
|
||||||
|
id: 'exam-open-001',
|
||||||
|
title: { en: 'Officer of the Watch — Written Examination' },
|
||||||
|
date: '2026-09-15T09:00:00.000Z',
|
||||||
|
venue: 'EMA Headquarters, Addis Ababa',
|
||||||
|
status: 'OPEN',
|
||||||
|
certification: { name: { en: 'Certificate of Competency (Deck)' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exam-open-002',
|
||||||
|
title: { en: 'Marine Engineering Practical Assessment' },
|
||||||
|
date: '2026-10-02T09:00:00.000Z',
|
||||||
|
venue: 'Bahir Dar Maritime Training Center',
|
||||||
|
status: 'OPEN',
|
||||||
|
certification: { name: { en: 'Certificate of Competency (Engine)' } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockMyExamRegistrations = [
|
||||||
|
{
|
||||||
|
id: 'reg-mock-001',
|
||||||
|
admissionNumber: 'ADM-2026-000876',
|
||||||
|
createdAt: '2026-08-01T08:00:00.000Z',
|
||||||
|
kind: 'NEW',
|
||||||
|
attemptNumber: 1,
|
||||||
|
attendanceStatus: 'REGISTERED',
|
||||||
|
exam: mockOpenExams[0],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockMyExamResults = [
|
||||||
|
{
|
||||||
|
id: 'result-mock-001',
|
||||||
|
totalScore: 82,
|
||||||
|
status: 'PASSED',
|
||||||
|
publishedAt: '2026-03-20T08:00:00.000Z',
|
||||||
|
remark: { en: 'Strong performance in navigation and safety.' },
|
||||||
|
exam: {
|
||||||
|
id: 'exam-past-001',
|
||||||
|
title: { en: 'Officer of the Watch — Written Examination' },
|
||||||
|
date: '2026-03-10T09:00:00.000Z',
|
||||||
|
venue: 'EMA Headquarters, Addis Ababa',
|
||||||
|
status: 'CLOSED',
|
||||||
|
certification: { name: { en: 'Certificate of Competency (Deck)' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockMyExamAppeals: any[] = [];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ notifications
|
||||||
|
|
||||||
|
export const mockNotifications = {
|
||||||
|
count: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'notif-mock-001',
|
||||||
|
subject: { en: 'Application under review' },
|
||||||
|
content: { en: 'Your vessel registration VES-2026-000123 is now under review.' },
|
||||||
|
isSeen: false,
|
||||||
|
itemId: 'app-vessel-pending-003',
|
||||||
|
itemType: 'LicenseApplication',
|
||||||
|
metadata: null,
|
||||||
|
createdAt: '2026-08-05T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notif-mock-002',
|
||||||
|
subject: { en: 'Corrections requested' },
|
||||||
|
content: { en: 'Please resubmit documents for VES-2026-000098.' },
|
||||||
|
isSeen: true,
|
||||||
|
itemId: 'app-vessel-resubmit-004',
|
||||||
|
itemType: 'LicenseApplication',
|
||||||
|
metadata: null,
|
||||||
|
createdAt: '2026-07-21T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ license types
|
||||||
|
|
||||||
|
export const mockLicenseTypeRequirements: Record<string, any> = {
|
||||||
|
VESSEL_REGISTRATION: {
|
||||||
|
licenseType: {
|
||||||
|
id: 'license-type-vessel-registration',
|
||||||
|
key: 'VESSEL_REGISTRATION',
|
||||||
|
name: { en: 'Vessel Registration' },
|
||||||
|
description: { en: 'Register a vessel with the Ethiopian Maritime Authority.' },
|
||||||
|
category: 'MARITIME_PERSONNEL',
|
||||||
|
certificatePrefix: 'VES',
|
||||||
|
feeNewApplication: 3500,
|
||||||
|
feeRenewal: 2000,
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
capitalThreshold: null,
|
||||||
|
validityMonths: 24,
|
||||||
|
slaHours: 240,
|
||||||
|
inspectionRequired: true,
|
||||||
|
issuesCertificate: true,
|
||||||
|
renewalEnabled: true,
|
||||||
|
requiresOperatorMode: false,
|
||||||
|
formSchema: {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: 'vesselDetails',
|
||||||
|
title: { en: 'Vessel Details' },
|
||||||
|
fields: [
|
||||||
|
{ key: 'vesselName', label: { en: 'Vessel Name' }, type: 'TEXT', required: true, sortOrder: 1 },
|
||||||
|
{ key: 'vesselType', label: { en: 'Vessel Type' }, type: 'TEXT', required: true, sortOrder: 2 },
|
||||||
|
{ key: 'grossTonnage', label: { en: 'Gross Tonnage' }, type: 'NUMBER', required: true, sortOrder: 3 },
|
||||||
|
],
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ownerDetails',
|
||||||
|
title: { en: 'Owner Details' },
|
||||||
|
fields: [
|
||||||
|
{ key: 'nationality', label: { en: 'Nationality' }, type: 'TEXT', required: true, sortOrder: 1 },
|
||||||
|
{ key: 'idNumber', label: { en: 'National ID (Fayda) Number' }, type: 'TEXT', required: true, sortOrder: 2 },
|
||||||
|
],
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
applicationKind: 'NEW',
|
||||||
|
fee: 3500,
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
documentRequirements: [
|
||||||
|
{
|
||||||
|
id: 'doc-vessel-ownership',
|
||||||
|
key: 'vesselOwnershipProof',
|
||||||
|
name: { en: 'Proof of Ownership' },
|
||||||
|
applicationKind: 'NEW',
|
||||||
|
mode: 'ALWAYS',
|
||||||
|
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
|
||||||
|
maxSizeMb: 10,
|
||||||
|
requiresValidityDates: false,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
staffRoleRequirements: [],
|
||||||
|
},
|
||||||
|
SEAFARER_REGISTRATION: {
|
||||||
|
licenseType: {
|
||||||
|
id: 'license-type-seafarer-registration',
|
||||||
|
key: 'SEAFARER_REGISTRATION',
|
||||||
|
name: { en: 'Seafarer Registration' },
|
||||||
|
description: { en: 'Register as a seafarer with the Ethiopian Maritime Authority.' },
|
||||||
|
category: 'MARITIME_PERSONNEL',
|
||||||
|
certificatePrefix: 'SEA',
|
||||||
|
feeNewApplication: 500,
|
||||||
|
feeRenewal: null,
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
capitalThreshold: null,
|
||||||
|
validityMonths: 0,
|
||||||
|
slaHours: 120,
|
||||||
|
inspectionRequired: false,
|
||||||
|
issuesCertificate: false,
|
||||||
|
renewalEnabled: false,
|
||||||
|
requiresOperatorMode: false,
|
||||||
|
formSchema: {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: 'personalDetails',
|
||||||
|
title: { en: 'Personal Details' },
|
||||||
|
fields: [
|
||||||
|
{ key: 'applicantName', label: { en: 'Full Name' }, type: 'TEXT', required: true, sortOrder: 1 },
|
||||||
|
{ key: 'department', label: { en: 'Department' }, type: 'SELECT', required: true, options: [
|
||||||
|
{ value: 'DECK', label: { en: 'Deck' } },
|
||||||
|
{ value: 'ENGINE', label: { en: 'Engine' } },
|
||||||
|
{ value: 'CATERING', label: { en: 'Catering' } },
|
||||||
|
], sortOrder: 2 },
|
||||||
|
],
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
applicationKind: 'NEW',
|
||||||
|
fee: 500,
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
documentRequirements: [
|
||||||
|
{
|
||||||
|
id: 'doc-seafarer-photo',
|
||||||
|
key: 'passportPhoto',
|
||||||
|
name: { en: 'Passport-size Photograph' },
|
||||||
|
applicationKind: 'NEW',
|
||||||
|
mode: 'ALWAYS',
|
||||||
|
allowedMimeTypes: ['image/jpeg', 'image/png'],
|
||||||
|
maxSizeMb: 5,
|
||||||
|
requiresValidityDates: false,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
staffRoleRequirements: [],
|
||||||
|
},
|
||||||
|
CERTIFICATE_OF_COMPETENCY: {
|
||||||
|
licenseType: {
|
||||||
|
id: 'license-type-coc',
|
||||||
|
key: 'CERTIFICATE_OF_COMPETENCY',
|
||||||
|
name: { en: 'Certificate of Competency' },
|
||||||
|
description: { en: 'Apply for a Certificate of Competency.' },
|
||||||
|
category: 'MARITIME_PERSONNEL',
|
||||||
|
certificatePrefix: 'COC',
|
||||||
|
feeNewApplication: 1200,
|
||||||
|
feeRenewal: 800,
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
capitalThreshold: null,
|
||||||
|
validityMonths: 60,
|
||||||
|
slaHours: 168,
|
||||||
|
inspectionRequired: false,
|
||||||
|
issuesCertificate: true,
|
||||||
|
renewalEnabled: true,
|
||||||
|
requiresOperatorMode: false,
|
||||||
|
formSchema: {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: 'applicantDetails',
|
||||||
|
title: { en: 'Applicant Details' },
|
||||||
|
fields: [
|
||||||
|
{ key: 'applicantName', label: { en: 'Full Name' }, type: 'TEXT', required: true, sortOrder: 1 },
|
||||||
|
{ key: 'rank', label: { en: 'Rank Applied For' }, type: 'TEXT', required: true, sortOrder: 2 },
|
||||||
|
],
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
applicationKind: 'NEW',
|
||||||
|
fee: 1200,
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
documentRequirements: [
|
||||||
|
{
|
||||||
|
id: 'doc-coc-seatime',
|
||||||
|
key: 'seaTimeRecord',
|
||||||
|
name: { en: 'Sea Time Record' },
|
||||||
|
applicationKind: 'NEW',
|
||||||
|
mode: 'ALWAYS',
|
||||||
|
allowedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMb: 10,
|
||||||
|
requiresValidityDates: false,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
staffRoleRequirements: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockLicenseTypes = Object.values(mockLicenseTypeRequirements).map(
|
||||||
|
(r) => r.licenseType,
|
||||||
|
);
|
||||||
@@ -506,6 +506,33 @@ export const licensingApi = baseApi
|
|||||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Places a candidate who has paid the examination fee into a sitting. */
|
||||||
|
scheduleExam: builder.mutation<
|
||||||
|
LicenseApplication,
|
||||||
|
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
|
url: `/license-application-review/${id}/schedule-exam`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the examination fee — after eligibility approval, or again when
|
||||||
|
* a failed candidate elects to resit.
|
||||||
|
*/
|
||||||
|
requestExamPayment: builder.mutation<LicenseApplication, string>({
|
||||||
|
query: (id) => ({
|
||||||
|
url: `/license-applications/${id}/request-exam-payment`,
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, id) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication')],
|
||||||
|
}),
|
||||||
|
|
||||||
// ------------------------------------------------- certificate designs
|
// ------------------------------------------------- certificate designs
|
||||||
getLicenseTemplates: builder.query<LicenseTemplate[], string | void>({
|
getLicenseTemplates: builder.query<LicenseTemplate[], string | void>({
|
||||||
query: (licenseTypeId) => ({
|
query: (licenseTypeId) => ({
|
||||||
@@ -543,6 +570,7 @@ export const licensingApi = baseApi
|
|||||||
name?: string;
|
name?: string;
|
||||||
hbsSource?: string;
|
hbsSource?: string;
|
||||||
pageOptions?: TemplatePageOptions;
|
pageOptions?: TemplatePageOptions;
|
||||||
|
backgroundUrl?: string;
|
||||||
}
|
}
|
||||||
>({
|
>({
|
||||||
query: ({ id, ...body }) => ({
|
query: ({ id, ...body }) => ({
|
||||||
@@ -827,6 +855,8 @@ export const {
|
|||||||
useApproveDocumentsMutation,
|
useApproveDocumentsMutation,
|
||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useRejectApplicationMutation,
|
useRejectApplicationMutation,
|
||||||
|
useScheduleExamMutation,
|
||||||
|
useRequestExamPaymentMutation,
|
||||||
useConfirmPaymentMutation,
|
useConfirmPaymentMutation,
|
||||||
useScheduleInspectionMutation,
|
useScheduleInspectionMutation,
|
||||||
useGetInspectionsQuery,
|
useGetInspectionsQuery,
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
|||||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||||
COMPLETED: 'Completed',
|
COMPLETED: 'Completed',
|
||||||
|
ELIGIBILITY_APPROVED: 'Eligible to Sit',
|
||||||
|
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
|
||||||
|
EXAM_PAID: 'Awaiting Exam Date',
|
||||||
|
EXAM_SCHEDULED: 'Exam Scheduled',
|
||||||
|
EXAM_PASSED: 'Exam Passed',
|
||||||
|
EXAM_FAILED: 'Exam Not Passed',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mantine colour per status — green progresses, orange needs the applicant. */
|
/** Mantine colour per status — green progresses, orange needs the applicant. */
|
||||||
@@ -89,6 +95,13 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
|||||||
PAYMENT_CONFIRMED: 'teal',
|
PAYMENT_CONFIRMED: 'teal',
|
||||||
CERTIFICATE_ISSUED: 'green',
|
CERTIFICATE_ISSUED: 'green',
|
||||||
COMPLETED: 'green',
|
COMPLETED: 'green',
|
||||||
|
ELIGIBILITY_APPROVED: 'teal',
|
||||||
|
EXAM_PAYMENT_PENDING: 'yellow',
|
||||||
|
EXAM_PAID: 'lime',
|
||||||
|
EXAM_SCHEDULED: 'cyan',
|
||||||
|
EXAM_PASSED: 'teal',
|
||||||
|
// Orange, not red: a failure is recoverable here — the candidate resits.
|
||||||
|
EXAM_FAILED: 'orange',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +127,15 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
|||||||
CERTIFICATE_ISSUED: 100,
|
CERTIFICATE_ISSUED: 100,
|
||||||
COMPLETED: 100,
|
COMPLETED: 100,
|
||||||
REJECTED: 100,
|
REJECTED: 100,
|
||||||
|
// The exam leg sits between approval and the certificate fee, so these
|
||||||
|
// interleave with PAYMENT_PENDING (80) rather than running past it.
|
||||||
|
ELIGIBILITY_APPROVED: 60,
|
||||||
|
EXAM_PAYMENT_PENDING: 64,
|
||||||
|
EXAM_PAID: 68,
|
||||||
|
EXAM_SCHEDULED: 72,
|
||||||
|
EXAM_PASSED: 78,
|
||||||
|
// A resit returns to the fee step, so this is not further along than a pass.
|
||||||
|
EXAM_FAILED: 64,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Statuses where nothing moves until the applicant does something. */
|
/** Statuses where nothing moves until the applicant does something. */
|
||||||
@@ -121,6 +143,10 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
|||||||
'DRAFT',
|
'DRAFT',
|
||||||
'RESUBMIT_REQUIRED',
|
'RESUBMIT_REQUIRED',
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
|
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
||||||
|
// sit again after a failure.
|
||||||
|
'EXAM_PAYMENT_PENDING',
|
||||||
|
'EXAM_FAILED',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Statuses that are finished, whichever way they went. */
|
/** Statuses that are finished, whichever way they went. */
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/** Shared licensing contract — mirrors the emaapi domain model. */
|
/** Shared licensing contract — mirrors the emaapi domain model. */
|
||||||
|
|
||||||
|
// Reused rather than redeclared: the department vocabulary belongs to the
|
||||||
|
// seafarer domain, and two copies would drift.
|
||||||
|
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
||||||
|
|
||||||
export type Bilingual = { en?: string; am?: string };
|
export type Bilingual = { en?: string; am?: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +25,15 @@ export type LicenseStatus =
|
|||||||
| "PAID"
|
| "PAID"
|
||||||
| "PAYMENT_CONFIRMED"
|
| "PAYMENT_CONFIRMED"
|
||||||
| "CERTIFICATE_ISSUED"
|
| "CERTIFICATE_ISSUED"
|
||||||
| "COMPLETED";
|
| "COMPLETED"
|
||||||
|
// Examined certificates (CoC, some CoP): approval establishes eligibility,
|
||||||
|
// the candidate pays to sit, and the certificate fee falls due on a pass.
|
||||||
|
| "ELIGIBILITY_APPROVED"
|
||||||
|
| "EXAM_PAYMENT_PENDING"
|
||||||
|
| "EXAM_PAID"
|
||||||
|
| "EXAM_SCHEDULED"
|
||||||
|
| "EXAM_PASSED"
|
||||||
|
| "EXAM_FAILED";
|
||||||
|
|
||||||
export type ApplicationKind = "NEW" | "RENEWAL";
|
export type ApplicationKind = "NEW" | "RENEWAL";
|
||||||
|
|
||||||
@@ -130,6 +142,58 @@ export interface LicenseType {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
/** Display order set by EMA; lower comes first. */
|
/** Display order set by EMA; lower comes first. */
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
|
||||||
|
// --------------------------------------------------- examined certificates
|
||||||
|
/** Approval establishes eligibility; the certificate is earned by exam. */
|
||||||
|
requiresExamination?: boolean;
|
||||||
|
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
|
||||||
|
feeExamination?: string | number | null;
|
||||||
|
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
|
||||||
|
feeCertificate?: string | number | null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- STCW mapping
|
||||||
|
certificateCategory?: CertificateCategory | null;
|
||||||
|
stcwControlled?: boolean;
|
||||||
|
/** Convention regulation, e.g. `III/2`. */
|
||||||
|
stcwRegulation?: string | null;
|
||||||
|
/** Code section, e.g. `A-III/2`. */
|
||||||
|
stcwCodeSection?: string | null;
|
||||||
|
stcwDepartment?: SeafarerDepartment | null;
|
||||||
|
competencyLevel?: CompetencyLevel | null;
|
||||||
|
stcwFunctions?: StcwFunctionRow[] | null;
|
||||||
|
stcwCapacities?: StcwCapacityRow[] | null;
|
||||||
|
/** Licence keys that must be held before this may be applied for. */
|
||||||
|
prerequisiteLicenseKeys?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What kind of document a certificate type produces. */
|
||||||
|
export type CertificateCategory =
|
||||||
|
| "COC"
|
||||||
|
| "COP"
|
||||||
|
| "ENDORSEMENT"
|
||||||
|
| "GOC"
|
||||||
|
| "NATIONAL";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STCW responsibility level. Cadet is absent by design — under STCW a cadet is
|
||||||
|
* a seafarer in training before holding any certificate, not a level of
|
||||||
|
* competence.
|
||||||
|
*/
|
||||||
|
export type CompetencyLevel = "SUPPORT" | "OPERATIONAL" | "MANAGEMENT";
|
||||||
|
|
||||||
|
/** One row of a CoC's function table (STCW Code A-I/2). */
|
||||||
|
export interface StcwFunctionRow {
|
||||||
|
function: Bilingual;
|
||||||
|
level: CompetencyLevel;
|
||||||
|
limitation?: Bilingual;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of a CoC's capacity table — what the holder may serve as. */
|
||||||
|
export interface StcwCapacityRow {
|
||||||
|
capacity: Bilingual;
|
||||||
|
limitation?: Bilingual;
|
||||||
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentRequirement {
|
export interface DocumentRequirement {
|
||||||
@@ -380,6 +444,11 @@ export interface LicenseTemplate {
|
|||||||
version: number;
|
version: number;
|
||||||
hbsSource: string;
|
hbsSource: string;
|
||||||
pageOptions: TemplatePageOptions | null;
|
pageOptions: TemplatePageOptions | null;
|
||||||
|
/**
|
||||||
|
* Artwork printed under the rendered text. A background, not the whole
|
||||||
|
* certificate — per-certificate data stays in the template layer above it.
|
||||||
|
*/
|
||||||
|
backgroundUrl?: string | null;
|
||||||
status: TemplateStatus;
|
status: TemplateStatus;
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -7,15 +7,33 @@ export const SESSION_HEADER_KEYS = {
|
|||||||
currentProjectId: 'x-current-project-id',
|
currentProjectId: 'x-current-project-id',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const TOKEN_STORAGE_KEYS = [
|
/**
|
||||||
'ema-backoffice-auth-token',
|
* Which app this bundle is, so it reads its own session and no one else's.
|
||||||
'ema-portal-auth-token',
|
*
|
||||||
'auth-token',
|
* Set by each app's store via `configureSessionScope`. Cookies ignore the
|
||||||
] as const;
|
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
|
||||||
|
* scope the backoffice would happily authenticate as whoever last signed into
|
||||||
|
* the portal, and render a staff console with an applicant's permissions.
|
||||||
|
*/
|
||||||
|
let scopedTokenKey: string | undefined;
|
||||||
|
|
||||||
|
export function configureSessionScope(prefix: string): void {
|
||||||
|
scopedTokenKey = `${prefix}-auth-token`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
|
||||||
|
const LEGACY_TOKEN_KEY = 'auth-token';
|
||||||
|
|
||||||
export function resolveTokenFromStorage(): string | undefined {
|
export function resolveTokenFromStorage(): string | undefined {
|
||||||
// cookie first, then localStorage (legacy pre-migration sessions)
|
// Only this app's key, then the legacy unprefixed one. Never another app's:
|
||||||
for (const key of TOKEN_STORAGE_KEYS) {
|
// falling through to a sibling's token is how a backoffice tab ends up
|
||||||
|
// holding a portal session.
|
||||||
|
const keys = scopedTokenKey
|
||||||
|
? [scopedTokenKey, LEGACY_TOKEN_KEY]
|
||||||
|
: [LEGACY_TOKEN_KEY];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
// cookie first, then localStorage (legacy pre-migration sessions)
|
||||||
const cookie = Cookies.get(key);
|
const cookie = Cookies.get(key);
|
||||||
if (cookie) return cookie;
|
if (cookie) return cookie;
|
||||||
const stored = localStorage.getItem(key);
|
const stored = localStorage.getItem(key);
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ interface ProtectedRouteProps {
|
|||||||
|
|
||||||
export function ProtectedRoute({ children, loginPath = '/login' }: ProtectedRouteProps) {
|
export function ProtectedRoute({ children, loginPath = '/login' }: ProtectedRouteProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
// `authStorage` is already scoped to this app; the bare key is only the
|
||||||
|
// legacy pre-prefix session. Never read a sibling app's token — that is how
|
||||||
|
// a backoffice tab ends up authenticated as a portal applicant.
|
||||||
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
Reference in New Issue
Block a user