mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 12:18:12 +00:00
Merge remote-tracking branch 'origin/certficate' into feature/exam-attempt-domain
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
ColorInput,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
block: TemplateFieldPlacement | null;
|
||||
variables: TemplateVariable[];
|
||||
onChange: (block: TemplateFieldPlacement) => void;
|
||||
onDelete: (id: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** Everything about the selected block that is not its position on the page. */
|
||||
export function BlockPropertiesPanel({
|
||||
block,
|
||||
variables,
|
||||
onChange,
|
||||
onDelete,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!block) {
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('designer.noBlockSelected', 'Select a block on the page to edit it.')}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const isLiteral = block.variable === null;
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.blockProperties', 'Selected block')}
|
||||
</Text>
|
||||
<Tooltip label={t('designer.deleteBlock', 'Remove block')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={disabled}
|
||||
onClick={() => onDelete(block.id)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={isLiteral ? 'text' : 'variable'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
value === 'text'
|
||||
? { ...block, variable: null, text: block.text ?? '' }
|
||||
: { ...block, variable: variables[0]?.key ?? 'companyName' },
|
||||
)
|
||||
}
|
||||
data={[
|
||||
{ value: 'variable', label: t('designer.blockVariable', 'Variable') },
|
||||
{ value: 'text', label: t('designer.blockText', 'Fixed text') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{isLiteral ? (
|
||||
<TextInput
|
||||
label={t('designer.blockTextLabel', 'Text')}
|
||||
value={block.text ?? ''}
|
||||
onChange={(e) => onChange({ ...block, text: e.currentTarget.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label={t('designer.blockVariableLabel', 'Variable')}
|
||||
data={variables.map((variable) => ({
|
||||
value: variable.key,
|
||||
label: variable.label,
|
||||
}))}
|
||||
value={block.variable}
|
||||
onChange={(value) => onChange({ ...block, variable: value })}
|
||||
searchable
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group gap="xs" grow>
|
||||
<NumberInput
|
||||
label={t('designer.blockFontSize', 'Font size')}
|
||||
value={block.fontSize ?? 14}
|
||||
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
|
||||
min={4}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('designer.blockWidth', 'Width (%)')}
|
||||
value={block.widthPct}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, widthPct: Math.min(100, Math.max(1, Number(value) || 1)) })
|
||||
}
|
||||
min={1}
|
||||
max={100}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" grow>
|
||||
<NumberInput
|
||||
label={t('designer.blockX', 'X (%)')}
|
||||
value={block.xPct}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, xPct: Math.min(100, Math.max(0, Number(value) || 0)) })
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
decimalScale={2}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('designer.blockY', 'Y (%)')}
|
||||
value={block.yPct}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, yPct: Math.min(100, Math.max(0, Number(value) || 0)) })
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
decimalScale={2}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={block.align ?? 'left'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
|
||||
}
|
||||
data={[
|
||||
{ value: 'left', label: t('designer.alignLeft', 'Left') },
|
||||
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
|
||||
{ value: 'right', label: t('designer.alignRight', 'Right') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={block.fontWeight ?? 'normal'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] })
|
||||
}
|
||||
data={[
|
||||
{ value: 'normal', label: t('designer.weightNormal', 'Normal') },
|
||||
{ value: 'bold', label: t('designer.weightBold', 'Bold') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ColorInput
|
||||
label={t('designer.blockColor', 'Colour')}
|
||||
value={block.color ?? '#111111'}
|
||||
onChange={(value) => onChange({ ...block, color: value })}
|
||||
disabled={disabled}
|
||||
format="hex"
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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';
|
||||
import { groupedTypeOptions } from '../config/designer';
|
||||
|
||||
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">
|
||||
{/* Grouped and searchable because the catalogue is 80+ types, over 50 of
|
||||
them STCW certificates: a flat list buries the CoC/CoP a user is
|
||||
looking for among the logistics operator licences. */}
|
||||
<Select
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={groupedTypeOptions(licenseTypes, localized, t)}
|
||||
value={typeId}
|
||||
onChange={onTypeChange}
|
||||
searchable
|
||||
nothingFoundMessage={t('designer.noTypeMatch', 'No licence type matches')}
|
||||
maxDropdownHeight={340}
|
||||
w={340}
|
||||
/>
|
||||
|
||||
{/* 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,225 @@
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconPhotoUp, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TemplateLogoCorner, TemplateLogoPlacement } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
backgroundUrl: string;
|
||||
onBackgroundChange: (url: string) => void;
|
||||
logoUrl: string;
|
||||
onLogoChange: (url: string) => void;
|
||||
logoPlacement: TemplateLogoPlacement;
|
||||
onLogoPlacementChange: (placement: TemplateLogoPlacement) => void;
|
||||
landscape: boolean;
|
||||
onLandscapeChange: (landscape: boolean) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const CORNERS: { value: TemplateLogoCorner; labelKey: string; fallback: string }[] = [
|
||||
{ value: 'TOP_LEFT', labelKey: 'designer.cornerTopLeft', fallback: 'Top left' },
|
||||
{ value: 'TOP_CENTER', labelKey: 'designer.cornerTopCenter', fallback: 'Top centre' },
|
||||
{ value: 'TOP_RIGHT', labelKey: 'designer.cornerTopRight', fallback: 'Top right' },
|
||||
{ value: 'BOTTOM_LEFT', labelKey: 'designer.cornerBottomLeft', fallback: 'Bottom left' },
|
||||
{ value: 'BOTTOM_RIGHT', labelKey: 'designer.cornerBottomRight', fallback: 'Bottom right' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Page-level settings: orientation, the artwork a certificate is printed on,
|
||||
* and the authority's logo.
|
||||
*
|
||||
* Orientation belongs here rather than beside the version name because it is a
|
||||
* property of the page, and it decides the shape of the canvas everything else
|
||||
* is positioned on.
|
||||
*/
|
||||
export function TemplateBackgroundPanel({
|
||||
backgroundUrl,
|
||||
onBackgroundChange,
|
||||
logoUrl,
|
||||
onLogoChange,
|
||||
logoPlacement,
|
||||
onLogoPlacementChange,
|
||||
landscape,
|
||||
onLandscapeChange,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.pageSetup', 'Page setup')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'designer.pageSetupHint',
|
||||
'Orientation, background artwork and the authority logo. Certificate data is drawn on top, so the same page serves every certificate.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Group gap="xl" align="flex-start">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t('designer.orientation', 'Orientation')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={landscape ? 'landscape' : 'portrait'}
|
||||
onChange={(value) => onLandscapeChange(value === 'landscape')}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{
|
||||
value: 'portrait',
|
||||
label: t('designer.portrait', 'Portrait'),
|
||||
},
|
||||
{
|
||||
value: 'landscape',
|
||||
label: t('designer.landscape', 'Landscape'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{landscape
|
||||
? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
|
||||
: t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Group align="flex-end" gap="sm">
|
||||
<TextInput
|
||||
label={t('designer.logoUrl', 'Institute logo URL')}
|
||||
placeholder="https://…"
|
||||
value={logoUrl}
|
||||
onChange={(e) => onLogoChange(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{logoUrl && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
disabled={disabled}
|
||||
onClick={() => onLogoChange('')}
|
||||
>
|
||||
{t('designer.removeLogo', 'Remove')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{logoUrl && (
|
||||
<Group align="flex-end" gap="sm">
|
||||
<Select
|
||||
label={t('designer.logoCorner', 'Logo position')}
|
||||
data={CORNERS.map((corner) => ({
|
||||
value: corner.value,
|
||||
label: t(corner.labelKey, corner.fallback),
|
||||
}))}
|
||||
value={logoPlacement.corner ?? 'TOP_LEFT'}
|
||||
onChange={(value) =>
|
||||
onLogoPlacementChange({
|
||||
...logoPlacement,
|
||||
corner: (value as TemplateLogoCorner) ?? 'TOP_LEFT',
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
w={170}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('designer.logoWidth', 'Width (% of page)')}
|
||||
value={logoPlacement.widthPct ?? 18}
|
||||
onChange={(value) =>
|
||||
onLogoPlacementChange({
|
||||
...logoPlacement,
|
||||
widthPct: Number(value) || 0,
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
max={100}
|
||||
disabled={disabled}
|
||||
w={150}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('designer.logoOffset', 'Inset (%)')}
|
||||
value={logoPlacement.offsetPct ?? 5}
|
||||
onChange={(value) =>
|
||||
onLogoPlacementChange({
|
||||
...logoPlacement,
|
||||
offsetPct: Number(value) || 0,
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
disabled={disabled}
|
||||
w={120}
|
||||
/>
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={t('designer.logoPreview', 'Logo preview')}
|
||||
radius="sm"
|
||||
fit="contain"
|
||||
h={56}
|
||||
w={56}
|
||||
fallbackSrc="data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Paper, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
backgroundUrl: string;
|
||||
logoUrl: string;
|
||||
logoPlacement: TemplateLogoPlacement;
|
||||
landscape: boolean;
|
||||
placements: TemplateFieldPlacement[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onChange: (placements: TemplateFieldPlacement[]) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** A4 aspect ratio, the only page size the renderer is configured for. */
|
||||
const A4_RATIO = 297 / 210;
|
||||
|
||||
const LOGO_CORNER_STYLE: Record<string, (offset: number) => React.CSSProperties> = {
|
||||
TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }),
|
||||
TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }),
|
||||
TOP_RIGHT: (o) => ({ top: `${o}%`, right: `${o}%` }),
|
||||
BOTTOM_LEFT: (o) => ({ bottom: `${o}%`, left: `${o}%` }),
|
||||
BOTTOM_RIGHT: (o) => ({ bottom: `${o}%`, right: `${o}%` }),
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
id: string;
|
||||
mode: 'move' | 'resize';
|
||||
pointerId: number;
|
||||
/** Grab offset within the block, in percent, so it does not jump on grab. */
|
||||
grabDxPct: number;
|
||||
grabDyPct: number;
|
||||
startWidthPct: number;
|
||||
startXPct: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The visual certificate editor.
|
||||
*
|
||||
* Blocks are positioned in percentages of the page box, which is what lets the
|
||||
* same layout render correctly in both orientations and at any zoom: the
|
||||
* canvas scales with its container, and the compiled Handlebars uses the same
|
||||
* percentages against the real A4 page.
|
||||
*
|
||||
* Pointer events rather than HTML5 drag-and-drop — the latter cannot report
|
||||
* continuous positions during a drag, and offers no path to resizing.
|
||||
*/
|
||||
export function TemplateCanvas({
|
||||
backgroundUrl,
|
||||
logoUrl,
|
||||
logoPlacement,
|
||||
landscape,
|
||||
placements,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onChange,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
|
||||
// Held in a ref as well: the pointer handlers are bound to the window for the
|
||||
// life of a drag, and would otherwise close over a stale placements array.
|
||||
const placementsRef = useRef(placements);
|
||||
placementsRef.current = placements;
|
||||
|
||||
const pointToPct = useCallback((clientX: number, clientY: number) => {
|
||||
const rect = pageRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0 || rect.height === 0) return { xPct: 0, yPct: 0 };
|
||||
return {
|
||||
xPct: ((clientX - rect.left) / rect.width) * 100,
|
||||
yPct: ((clientY - rect.top) / rect.height) * 100,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
|
||||
function handleMove(event: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const { xPct, yPct } = pointToPct(event.clientX, event.clientY);
|
||||
|
||||
onChange(
|
||||
placementsRef.current.map((block) => {
|
||||
if (block.id !== drag.id) return block;
|
||||
|
||||
if (drag.mode === 'resize') {
|
||||
// Width follows the pointer's distance from the block's left edge,
|
||||
// clamped so a block can neither invert nor leave the page.
|
||||
const width = Math.min(
|
||||
100 - drag.startXPct,
|
||||
Math.max(5, xPct - drag.startXPct),
|
||||
);
|
||||
return { ...block, widthPct: Number(width.toFixed(2)) };
|
||||
}
|
||||
|
||||
const nextX = Math.min(
|
||||
100 - block.widthPct,
|
||||
Math.max(0, xPct - drag.grabDxPct),
|
||||
);
|
||||
const nextY = Math.min(99, Math.max(0, yPct - drag.grabDyPct));
|
||||
return {
|
||||
...block,
|
||||
xPct: Number(nextX.toFixed(2)),
|
||||
yPct: Number(nextY.toFixed(2)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handleUp() {
|
||||
setDrag(null);
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
window.addEventListener('pointercancel', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
window.removeEventListener('pointercancel', handleUp);
|
||||
};
|
||||
}, [drag, onChange, pointToPct]);
|
||||
|
||||
function startDrag(
|
||||
event: React.PointerEvent,
|
||||
block: TemplateFieldPlacement,
|
||||
mode: 'move' | 'resize',
|
||||
) {
|
||||
if (disabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect(block.id);
|
||||
const { xPct, yPct } = pointToPct(event.clientX, event.clientY);
|
||||
setDrag({
|
||||
id: block.id,
|
||||
mode,
|
||||
pointerId: event.pointerId,
|
||||
grabDxPct: xPct - block.xPct,
|
||||
grabDyPct: yPct - block.yPct,
|
||||
startWidthPct: block.widthPct,
|
||||
startXPct: block.xPct,
|
||||
});
|
||||
}
|
||||
|
||||
/** Arrow keys nudge the selected block, for placement finer than a drag. */
|
||||
function handleKeyDown(event: React.KeyboardEvent, block: TemplateFieldPlacement) {
|
||||
if (disabled) return;
|
||||
const step = event.shiftKey ? 5 : 0.5;
|
||||
const deltas: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-step, 0],
|
||||
ArrowRight: [step, 0],
|
||||
ArrowUp: [0, -step],
|
||||
ArrowDown: [0, step],
|
||||
};
|
||||
const delta = deltas[event.key];
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
onChange(
|
||||
placements.map((candidate) =>
|
||||
candidate.id === block.id
|
||||
? {
|
||||
...candidate,
|
||||
xPct: Number(
|
||||
Math.min(100 - candidate.widthPct, Math.max(0, candidate.xPct + delta[0])).toFixed(2),
|
||||
),
|
||||
yPct: Number(Math.min(99, Math.max(0, candidate.yPct + delta[1])).toFixed(2)),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const corner = logoPlacement.corner ?? 'TOP_LEFT';
|
||||
const logoStyle = (LOGO_CORNER_STYLE[corner] ?? LOGO_CORNER_STYLE.TOP_LEFT)(
|
||||
logoPlacement.offsetPct ?? 5,
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
'designer.canvasHint',
|
||||
'Drag a block to move it, drag its right edge to resize, or use the arrow keys for fine placement.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
ref={pageRef}
|
||||
onPointerDown={() => onSelect(null)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO),
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--mantine-color-gray-4)',
|
||||
overflow: 'hidden',
|
||||
// Blocks are positioned against this box, so it must not be static.
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{backgroundUrl && (
|
||||
<img
|
||||
src={backgroundUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: `${logoPlacement.widthPct ?? 18}%`,
|
||||
pointerEvents: 'none',
|
||||
...logoStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{placements.map((block) => {
|
||||
const isSelected = block.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPointerDown={(event) => startDrag(event, block, 'move')}
|
||||
onKeyDown={(event) => handleKeyDown(event, block)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${block.xPct}%`,
|
||||
top: `${block.yPct}%`,
|
||||
width: `${block.widthPct}%`,
|
||||
fontSize: block.fontSize ?? 14,
|
||||
fontWeight: block.fontWeight ?? 'normal',
|
||||
textAlign: block.align ?? 'left',
|
||||
color: block.color ?? '#111111',
|
||||
cursor: disabled ? 'default' : 'move',
|
||||
outline: isSelected
|
||||
? '2px solid var(--mantine-color-blue-6)'
|
||||
: '1px dashed var(--mantine-color-gray-5)',
|
||||
background: isSelected ? 'rgba(34,139,230,0.06)' : 'transparent',
|
||||
boxSizing: 'border-box',
|
||||
padding: 2,
|
||||
lineHeight: 1.3,
|
||||
wordWrap: 'break-word',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{block.variable ? `{{${block.variable}}}` : block.text || ' '}
|
||||
|
||||
{isSelected && !disabled && (
|
||||
<span
|
||||
onPointerDown={(event) => startDrag(event, block, 'resize')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 8,
|
||||
height: 18,
|
||||
borderRadius: 2,
|
||||
background: 'var(--mantine-color-blue-6)',
|
||||
cursor: 'ew-resize',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</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,75 @@
|
||||
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Variable {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
variables: Variable[];
|
||||
disabled: boolean;
|
||||
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
|
||||
canvasMode: boolean;
|
||||
onInsert: (key: string) => void;
|
||||
onAddBlock: (key: string) => void;
|
||||
onAddTextBlock: () => void;
|
||||
}
|
||||
|
||||
/** Placeholders the design can carry — dropped on the page, or typed at the caret. */
|
||||
export function TemplateVariableList({
|
||||
variables,
|
||||
disabled,
|
||||
canvasMode,
|
||||
onInsert,
|
||||
onAddBlock,
|
||||
onAddTextBlock,
|
||||
}: 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">
|
||||
{canvasMode
|
||||
? t('designer.variablesCanvasHint', 'Click to add a block to the page.')
|
||||
: t('designer.variablesHint', 'Click to insert at the cursor.')}
|
||||
</Text>
|
||||
|
||||
{canvasMode && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
disabled={disabled}
|
||||
onClick={onAddTextBlock}
|
||||
>
|
||||
{t('designer.addTextBlock', 'Fixed text block')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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={() =>
|
||||
canvasMode ? onAddBlock(variable.key) : 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,71 @@
|
||||
import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } 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:3000/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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the category groups appear in, and their fallback labels.
|
||||
*
|
||||
* Seafarer certification sits first: it holds over 50 of the 80-odd licence
|
||||
* types, and it is what the designer is opened for most often. Any category
|
||||
* not listed here still renders — it falls to the end under its own key —
|
||||
* so a new category on the server does not silently hide its types.
|
||||
*/
|
||||
const CATEGORY_ORDER: { key: LicenseCategory; fallback: string }[] = [
|
||||
{ key: 'MARITIME_PERSONNEL', fallback: 'Seafarer certification (CoC / CoP)' },
|
||||
{ key: 'CARGO_FREIGHT', fallback: 'Cargo & freight' },
|
||||
{ key: 'SHIPPING_AGENCY', fallback: 'Shipping agency' },
|
||||
{ key: 'INVESTMENT', fallback: 'Investment & joint ventures' },
|
||||
{ key: 'VESSEL_SERVICES', fallback: 'Vessel services' },
|
||||
{ key: 'WAIVER_SERVICES', fallback: 'Waiver services' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The licence-type picker's options, grouped by category.
|
||||
*
|
||||
* Grouping is what makes the picker usable at all: certificates of competency
|
||||
* and proficiency are a different kind of thing from an operator's logistics
|
||||
* licence, and a flat alphabetical list interleaves the two.
|
||||
*/
|
||||
export function groupedTypeOptions(
|
||||
licenseTypes: LicenseType[],
|
||||
localized: (value?: Bilingual) => string,
|
||||
t: (key: string, fallback: string) => string,
|
||||
) {
|
||||
const byCategory = new Map<string, { value: string; label: string }[]>();
|
||||
|
||||
for (const type of licenseTypes) {
|
||||
const option = { value: type.id, label: localized(type.name) || type.key };
|
||||
const bucket = byCategory.get(type.category);
|
||||
if (bucket) bucket.push(option);
|
||||
else byCategory.set(type.category, [option]);
|
||||
}
|
||||
|
||||
const known = CATEGORY_ORDER.map(({ key, fallback }) => ({
|
||||
group: t(`designer.category.${key}`, fallback),
|
||||
items: (byCategory.get(key) ?? []).sort((a, b) => a.label.localeCompare(b.label)),
|
||||
}));
|
||||
|
||||
// Categories the client does not know about yet, so their types stay reachable.
|
||||
const unknown = [...byCategory.entries()]
|
||||
.filter(([key]) => !CATEGORY_ORDER.some((c) => c.key === key))
|
||||
.map(([key, items]) => ({
|
||||
group: key,
|
||||
items: items.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
}));
|
||||
|
||||
return [...known, ...unknown].filter((group) => group.items.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Client-side twin of the server's `certificate-layout.compiler`.
|
||||
*
|
||||
* The preview route renders whatever `hbsSource` it is handed, so a canvas
|
||||
* layout has to be compiled before it can be previewed — the server only
|
||||
* compiles on save, and previewing unsaved edits is the whole point of the
|
||||
* button. The two implementations must emit the same HTML; the server's copy
|
||||
* is authoritative for what is stored, this one only ever reaches a preview.
|
||||
*/
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function pct(value: number | undefined, fallback: number, min = 0): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return fallback;
|
||||
return Math.min(100, Math.max(min, value));
|
||||
}
|
||||
|
||||
const CORNER_STYLES: Record<string, (offset: number) => string> = {
|
||||
TOP_LEFT: (o) => `top:${o}%;left:${o}%;`,
|
||||
TOP_CENTER: (o) => `top:${o}%;left:50%;transform:translateX(-50%);`,
|
||||
TOP_RIGHT: (o) => `top:${o}%;right:${o}%;`,
|
||||
BOTTOM_LEFT: (o) => `bottom:${o}%;left:${o}%;`,
|
||||
BOTTOM_RIGHT: (o) => `bottom:${o}%;right:${o}%;`,
|
||||
};
|
||||
|
||||
function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string {
|
||||
if (!logoUrl.trim()) return '';
|
||||
const corner = placement.corner ?? 'TOP_LEFT';
|
||||
const width = pct(placement.widthPct, 18);
|
||||
const offset = pct(placement.offsetPct, 5);
|
||||
const position = (CORNER_STYLES[corner] ?? CORNER_STYLES.TOP_LEFT)(offset);
|
||||
return ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\n`;
|
||||
}
|
||||
|
||||
function blockHtml(block: TemplateFieldPlacement): string {
|
||||
const x = pct(block.xPct, 0);
|
||||
const y = pct(block.yPct, 0);
|
||||
// Minimum 1%, matching the server compiler: a zero-width block would render
|
||||
// as an invisible sliver rather than as the mistake it is.
|
||||
const width = pct(block.widthPct, 30, 1);
|
||||
const size = block.fontSize ?? 14;
|
||||
const weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
|
||||
const align = block.align ?? 'left';
|
||||
const color = escapeHtml(block.color ?? '#111111');
|
||||
|
||||
const content = block.variable
|
||||
? `{{${block.variable}}}`
|
||||
: escapeHtml(block.text ?? '');
|
||||
|
||||
const style =
|
||||
`position:absolute;left:${x}%;top:${y}%;width:${width}%;` +
|
||||
`font-size:${size}px;font-weight:${weight};text-align:${align};color:${color};`;
|
||||
|
||||
return ` <div class="ema-block" style="${style}">${content}</div>\n`;
|
||||
}
|
||||
|
||||
export function compileLayoutToHbs(input: {
|
||||
backgroundUrl: string;
|
||||
logoUrl: string;
|
||||
logoPlacement: TemplateLogoPlacement;
|
||||
fieldPlacements: TemplateFieldPlacement[];
|
||||
}): string {
|
||||
const background = input.backgroundUrl.trim()
|
||||
? ` <img class="ema-background" src="${escapeHtml(input.backgroundUrl)}" alt="" />\n`
|
||||
: '';
|
||||
|
||||
const blocks = input.fieldPlacements.map(blockHtml).join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
@page { margin: 0; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; }
|
||||
body { font-family: "Helvetica Neue", Arial, sans-serif; }
|
||||
.ema-page { position: relative; width: 100%; height: 100vh; overflow: hidden; }
|
||||
.ema-background {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.ema-block { box-sizing: border-box; line-height: 1.3; word-wrap: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ema-page">
|
||||
${background}${logoHtml(input.logoUrl, input.logoPlacement)}${blocks} </div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -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,160 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
LicenseTemplate,
|
||||
TemplateFieldPlacement,
|
||||
TemplateLogoPlacement,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/** Stable ids for new blocks; `crypto.randomUUID` is not in every test env. */
|
||||
function blockId(): string {
|
||||
return `blk_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [logoUrl, setLogoUrl] = useState('');
|
||||
const [logoPlacement, setLogoPlacement] = useState<TemplateLogoPlacement>({});
|
||||
const [placements, setPlacements] = useState<TemplateFieldPlacement[]>([]);
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
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 ?? '');
|
||||
setLogoUrl(selected.logoUrl ?? '');
|
||||
setLogoPlacement(selected.logoPlacement ?? {});
|
||||
setPlacements(selected.fieldPlacements ?? []);
|
||||
setSelectedBlockId(null);
|
||||
}, [selected]);
|
||||
|
||||
const isPublished = selected?.status === 'PUBLISHED';
|
||||
|
||||
// Compared as JSON because both are plain data the server round-trips; a
|
||||
// reference check would mark the draft dirty on every render.
|
||||
const placementsChanged =
|
||||
JSON.stringify(placements) !== JSON.stringify(selected?.fieldPlacements ?? []);
|
||||
const logoPlacementChanged =
|
||||
JSON.stringify(logoPlacement) !== JSON.stringify(selected?.logoPlacement ?? {});
|
||||
|
||||
const dirty =
|
||||
Boolean(selected) &&
|
||||
(source !== selected?.hbsSource ||
|
||||
name !== selected?.name ||
|
||||
landscape !== (selected?.pageOptions?.landscape ?? true) ||
|
||||
backgroundUrl !== (selected?.backgroundUrl ?? '') ||
|
||||
logoUrl !== (selected?.logoUrl ?? '') ||
|
||||
logoPlacementChanged ||
|
||||
placementsChanged);
|
||||
|
||||
/** True once the canvas owns the layout, which locks the raw editor. */
|
||||
const usesCanvas = placements.length > 0;
|
||||
|
||||
const selectedBlock =
|
||||
placements.find((block) => block.id === selectedBlockId) ?? null;
|
||||
|
||||
/** Drops a new block near the top-left, where it is immediately visible. */
|
||||
const addBlock = useCallback((variable: string | null, text?: string) => {
|
||||
const block: TemplateFieldPlacement = {
|
||||
id: blockId(),
|
||||
variable,
|
||||
text,
|
||||
xPct: 10,
|
||||
yPct: 10,
|
||||
widthPct: 30,
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal',
|
||||
align: 'left',
|
||||
color: '#111111',
|
||||
};
|
||||
setPlacements((prev) => [...prev, block]);
|
||||
setSelectedBlockId(block.id);
|
||||
}, []);
|
||||
|
||||
const updateBlock = useCallback((next: TemplateFieldPlacement) => {
|
||||
setPlacements((prev) =>
|
||||
prev.map((block) => (block.id === next.id ? next : block)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const deleteBlock = useCallback((id: string) => {
|
||||
setPlacements((prev) => prev.filter((block) => block.id !== id));
|
||||
setSelectedBlockId((current) => (current === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/** 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,
|
||||
logoUrl,
|
||||
setLogoUrl,
|
||||
logoPlacement,
|
||||
setLogoPlacement,
|
||||
placements,
|
||||
setPlacements,
|
||||
selectedBlockId,
|
||||
setSelectedBlockId,
|
||||
selectedBlock,
|
||||
usesCanvas,
|
||||
addBlock,
|
||||
updateBlock,
|
||||
deleteBlock,
|
||||
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,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDeviceFloppy,
|
||||
IconEye,
|
||||
IconCode,
|
||||
IconLayoutBoard,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconRosetteDiscountCheck,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -39,26 +27,26 @@ import {
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
useLocalized,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
type LicenseTemplate,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
|
||||
import { authStorage, usePermissions } from '@ema-platform/auth';
|
||||
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
|
||||
/** Same resolution the shared RTK Query baseQuery uses. */
|
||||
const API_BASE_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
PUBLISHED: 'teal',
|
||||
ARCHIVED: 'dark',
|
||||
};
|
||||
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
|
||||
import { DesignerToolbar } from '../components/DesignerToolbar';
|
||||
import { NewVersionModal } from '../components/NewVersionModal';
|
||||
import { TemplateActionBar } from '../components/TemplateActionBar';
|
||||
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
|
||||
import { TemplateCanvas } from '../components/TemplateCanvas';
|
||||
import { TemplateEditor } from '../components/TemplateEditor';
|
||||
import { TemplateVariableList } from '../components/TemplateVariableList';
|
||||
import { TemplateVersionList } from '../components/TemplateVersionList';
|
||||
import { pageOptionsFor } from '../config/designer';
|
||||
import { compileLayoutToHbs } from '../config/layout-compiler';
|
||||
import { useDesignerActions } from '../hooks/useDesignerActions';
|
||||
import { useTemplateDraft } from '../hooks/useTemplateDraft';
|
||||
import { useTemplatePreview } from '../hooks/useTemplatePreview';
|
||||
|
||||
/**
|
||||
* Where the authority designs the certificate its licensees receive.
|
||||
@@ -71,7 +59,6 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
*/
|
||||
export function CertificateDesignerPage() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||
@@ -97,16 +84,16 @@ export function CertificateDesignerPage() {
|
||||
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
|
||||
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [source, setSource] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [landscape, setLandscape] = useState(true);
|
||||
const draft = useTemplateDraft(templates);
|
||||
const run = useDesignerActions();
|
||||
const openPreview = useTemplatePreview();
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [validityMonths, setValidityMonths] = useState<number>(12);
|
||||
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
const [validityMonths, setValidityMonths] = useState<number>(12);
|
||||
|
||||
// Default to the first licence type so the page is never an empty shell.
|
||||
useEffect(() => {
|
||||
@@ -117,101 +104,14 @@ export function CertificateDesignerPage() {
|
||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||
}, [selectedType]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
|
||||
[templates, selectedId],
|
||||
);
|
||||
|
||||
// Pick the live design by default — that is the one staff usually want.
|
||||
useEffect(() => {
|
||||
if (!templates.length) {
|
||||
setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
|
||||
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
|
||||
setSelectedId((published ?? templates[0]).id);
|
||||
}, [templates, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
setSource(selected.hbsSource);
|
||||
setName(selected.name);
|
||||
setLandscape(selected.pageOptions?.landscape ?? true);
|
||||
}, [selected]);
|
||||
|
||||
const isPublished = selected?.status === 'PUBLISHED';
|
||||
const dirty =
|
||||
Boolean(selected) &&
|
||||
(source !== selected?.hbsSource ||
|
||||
name !== selected?.name ||
|
||||
landscape !== (selected?.pageOptions?.landscape ?? true));
|
||||
|
||||
async function run(action: () => Promise<unknown>, success: string) {
|
||||
try {
|
||||
await action();
|
||||
notifications.show({ color: 'teal', title: success, message: '' });
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('designer.actionFailed', 'Action failed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
function startNewVersion() {
|
||||
setNewName(
|
||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||
);
|
||||
setNewOpen(true);
|
||||
}
|
||||
|
||||
/** Inserts a placeholder where the caret is, rather than at the end. */
|
||||
function insertVariable(key: string) {
|
||||
const el = editorRef.current;
|
||||
const token = `{{${key}}}`;
|
||||
if (!el) {
|
||||
setSource((prev) => prev + token);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? source.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
setSource(source.slice(0, start) + token + source.slice(end));
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the editor's current contents, not the saved row, so unsaved edits
|
||||
* are what you see. Opened as a blob so it never leaves a file behind.
|
||||
*/
|
||||
async function preview() {
|
||||
try {
|
||||
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
|
||||
// and calls the API directly — which means spelling out the base URL and
|
||||
// the bearer token that the shared baseQuery would normally attach.
|
||||
const token = authStorage.getToken();
|
||||
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hbsSource: source,
|
||||
licenseTypeId: typeId,
|
||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
window.open(url, '_blank', 'noopener');
|
||||
// Give the new tab time to read it before revoking.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('designer.previewFailed', 'Could not render the preview'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
const editingLocked = !canEdit || draft.isPublished;
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -223,74 +123,26 @@ export function CertificateDesignerPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Group align="flex-end" mb="md" gap="sm">
|
||||
<Select
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: localized(type.name) || type.key,
|
||||
}))}
|
||||
value={typeId}
|
||||
onChange={(value) => {
|
||||
setTypeId(value);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
w={280}
|
||||
/>
|
||||
|
||||
{/* Validity lives beside the design because it is the other half of
|
||||
what a certificate promises. */}
|
||||
<NumberInput
|
||||
label={t('designer.validityYears', 'Valid for (years)')}
|
||||
description={t('designer.validityHint', 'Applied when a licence is issued')}
|
||||
value={Number((validityMonths / 12).toFixed(2))}
|
||||
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
decimalScale={1}
|
||||
w={190}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
canEdit
|
||||
? t('designer.saveValidity', 'Save validity')
|
||||
: t('designer.noPermission', 'You do not have permission')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={savingValidity}
|
||||
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||
t('designer.validitySaved', 'Validity updated'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.saveValidity', 'Save validity')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
disabled={!canEdit || !typeId}
|
||||
onClick={() => {
|
||||
setNewName(
|
||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||
);
|
||||
setNewOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('designer.newVersion', 'New version')}
|
||||
</Button>
|
||||
</Group>
|
||||
<DesignerToolbar
|
||||
licenseTypes={licenseTypes?.items ?? []}
|
||||
typeId={typeId}
|
||||
onTypeChange={(value) => {
|
||||
setTypeId(value);
|
||||
draft.setSelectedId(null);
|
||||
}}
|
||||
validityMonths={validityMonths}
|
||||
onValidityChange={setValidityMonths}
|
||||
currentValidityMonths={selectedType?.validityMonths}
|
||||
canEdit={canEdit}
|
||||
savingValidity={savingValidity}
|
||||
onSaveValidity={() =>
|
||||
run(
|
||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||
t('designer.validitySaved', 'Validity updated'),
|
||||
)
|
||||
}
|
||||
onNewVersion={startNewVersion}
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
@@ -308,253 +160,223 @@ export function CertificateDesignerPage() {
|
||||
)}
|
||||
action={
|
||||
canEdit
|
||||
? {
|
||||
label: t('designer.newVersion', 'New version'),
|
||||
onClick: () => {
|
||||
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
|
||||
setNewOpen(true);
|
||||
},
|
||||
}
|
||||
? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Group align="flex-start" gap="md" wrap="nowrap">
|
||||
{/* Versions */}
|
||||
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.versions', 'Versions')}
|
||||
</Text>
|
||||
{templates.map((tpl) => (
|
||||
<Card
|
||||
key={tpl.id}
|
||||
withBorder
|
||||
padding="xs"
|
||||
onClick={() => setSelectedId(tpl.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor:
|
||||
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{tpl.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{tpl.version}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
|
||||
{tpl.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
<TemplateVersionList
|
||||
templates={templates}
|
||||
selectedId={draft.selectedId}
|
||||
onSelect={draft.setSelectedId}
|
||||
/>
|
||||
|
||||
{/* Editor */}
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="sm" align="flex-end">
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
disabled={!canEdit || isPublished}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Switch
|
||||
label={t('designer.landscape', 'Landscape')}
|
||||
checked={landscape}
|
||||
onChange={(e) => setLandscape(e.currentTarget.checked)}
|
||||
disabled={!canEdit || isPublished}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isPublished && (
|
||||
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'designer.publishedLocked',
|
||||
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={editorRef}
|
||||
label={t('designer.source', 'Template (Handlebars + HTML)')}
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.currentTarget.value)}
|
||||
disabled={!canEdit || isPublished}
|
||||
autosize
|
||||
minRows={18}
|
||||
maxRows={30}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
|
||||
<TemplateBackgroundPanel
|
||||
backgroundUrl={draft.backgroundUrl}
|
||||
onBackgroundChange={draft.setBackgroundUrl}
|
||||
logoUrl={draft.logoUrl}
|
||||
onLogoChange={draft.setLogoUrl}
|
||||
logoPlacement={draft.logoPlacement}
|
||||
onLogoPlacementChange={draft.setLogoPlacement}
|
||||
landscape={draft.landscape}
|
||||
onLandscapeChange={draft.setLandscape}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
{/* A published design is immutable by rule -- certificates were
|
||||
issued from it -- so the editor is locked. Without this the
|
||||
screen looks broken rather than deliberately read-only, which
|
||||
is what "edit is not available" turns out to mean. */}
|
||||
{draft.isPublished && (
|
||||
<Alert
|
||||
variant="light"
|
||||
leftSection={<IconEye size={16} />}
|
||||
onClick={preview}
|
||||
disabled={!source.trim()}
|
||||
color="blue"
|
||||
icon={<IconLock size={18} />}
|
||||
title={t('designer.liveDesign', 'This is the live design')}
|
||||
>
|
||||
{t('designer.preview', 'Preview PDF')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
loading={saving}
|
||||
disabled={!canEdit || isPublished || !dirty}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
updateTemplate({
|
||||
id: selected!.id,
|
||||
name,
|
||||
hbsSource: source,
|
||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
||||
}).unwrap(),
|
||||
t('designer.saved', 'Draft saved'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.save', 'Save draft')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
!canPublish
|
||||
? t('designer.noPublishPermission', 'You cannot publish designs')
|
||||
: dirty
|
||||
? t('designer.saveFirst', 'Save your changes first')
|
||||
: t('designer.publishHint', 'Makes this the live certificate design')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconRosetteDiscountCheck size={16} />}
|
||||
loading={publishing}
|
||||
disabled={!canPublish || isPublished || dirty || !selected}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => publishTemplate(selected!.id).unwrap(),
|
||||
t('designer.published', 'Design published'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.publish', 'Publish')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div style={{ flex: 1 }} />
|
||||
{selected && isPublished && canPublish && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() =>
|
||||
run(
|
||||
() => archiveTemplate(selected.id).unwrap(),
|
||||
t('designer.archived', 'Design withdrawn'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.archive', 'Withdraw')}
|
||||
</Button>
|
||||
)}
|
||||
{selected && !isPublished && canEdit && (
|
||||
<Tooltip label={t('designer.delete', 'Delete draft')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t('designer.delete', 'Delete draft')}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => deleteTemplate(selected.id).unwrap(),
|
||||
t('designer.deleted', 'Draft deleted'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap="xs" align="flex-start">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'designer.liveDesignBody',
|
||||
'Certificates have been issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.',
|
||||
)}
|
||||
</Text>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={startNewVersion}
|
||||
>
|
||||
{t('designer.newVersionFromThis', 'New version from this design')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value={mode} onChange={(value) => setMode(value as 'canvas' | 'source')}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="canvas" leftSection={<IconLayoutBoard size={15} />}>
|
||||
{t('designer.tabCanvas', 'Visual editor')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="source" leftSection={<IconCode size={15} />}>
|
||||
{t('designer.tabSource', 'HTML source')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="canvas" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={draft.name}
|
||||
onChange={(e) => draft.setName(e.currentTarget.value)}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
<TemplateCanvas
|
||||
backgroundUrl={draft.backgroundUrl}
|
||||
logoUrl={draft.logoUrl}
|
||||
logoPlacement={draft.logoPlacement}
|
||||
landscape={draft.landscape}
|
||||
placements={draft.placements}
|
||||
selectedId={draft.selectedBlockId}
|
||||
onSelect={draft.setSelectedBlockId}
|
||||
onChange={draft.setPlacements}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
<BlockPropertiesPanel
|
||||
block={draft.selectedBlock}
|
||||
variables={variables}
|
||||
onChange={draft.updateBlock}
|
||||
onDelete={draft.deleteBlock}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="source" pt="sm">
|
||||
{draft.usesCanvas && (
|
||||
<Paper withBorder p="xs" mb="sm" bg="var(--mantine-color-yellow-light)">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'designer.canvasOwnsSource',
|
||||
'This design is laid out on the visual editor, which regenerates the HTML on every save. Edits made here will be overwritten — remove all blocks first to hand-write the template.',
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
<TemplateEditor
|
||||
name={draft.name}
|
||||
onNameChange={draft.setName}
|
||||
source={draft.source}
|
||||
onSourceChange={draft.setSource}
|
||||
landscape={draft.landscape}
|
||||
onLandscapeChange={draft.setLandscape}
|
||||
editorRef={draft.editorRef}
|
||||
disabled={editingLocked}
|
||||
isPublished={draft.isPublished}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<TemplateActionBar
|
||||
// A canvas layout has no Handlebars source until it is saved --
|
||||
// the server compiles it -- so gating preview on the source alone
|
||||
// left the button dead for exactly the designs the canvas is for.
|
||||
hasSource={Boolean(draft.source.trim()) || draft.usesCanvas}
|
||||
hasSelection={Boolean(draft.selected)}
|
||||
isPublished={draft.isPublished}
|
||||
dirty={draft.dirty}
|
||||
canEdit={canEdit}
|
||||
canPublish={canPublish}
|
||||
saving={saving}
|
||||
publishing={publishing}
|
||||
onPreview={() =>
|
||||
openPreview({
|
||||
// A canvas layout is compiled here so the preview shows
|
||||
// unsaved block moves; the server compiles the stored copy.
|
||||
hbsSource: draft.usesCanvas
|
||||
? compileLayoutToHbs({
|
||||
backgroundUrl: draft.backgroundUrl,
|
||||
logoUrl: draft.logoUrl,
|
||||
logoPlacement: draft.logoPlacement,
|
||||
fieldPlacements: draft.placements,
|
||||
})
|
||||
: draft.source,
|
||||
licenseTypeId: typeId,
|
||||
landscape: draft.landscape,
|
||||
})
|
||||
}
|
||||
onSave={() =>
|
||||
run(
|
||||
() =>
|
||||
updateTemplate({
|
||||
id: draft.selected!.id,
|
||||
name: draft.name,
|
||||
// The server recompiles the HTML from the blocks when a
|
||||
// canvas layout is present, so sending the stale source
|
||||
// alongside it would only fight that.
|
||||
hbsSource: draft.usesCanvas ? undefined : draft.source,
|
||||
pageOptions: pageOptionsFor(draft.landscape),
|
||||
backgroundUrl: draft.backgroundUrl || undefined,
|
||||
logoUrl: draft.logoUrl || undefined,
|
||||
logoPlacement: draft.logoPlacement,
|
||||
fieldPlacements: draft.placements,
|
||||
}).unwrap(),
|
||||
t('designer.saved', 'Draft saved'),
|
||||
)
|
||||
}
|
||||
onPublish={() =>
|
||||
run(
|
||||
() => publishTemplate(draft.selected!.id).unwrap(),
|
||||
t('designer.published', 'Design published'),
|
||||
)
|
||||
}
|
||||
onArchive={() =>
|
||||
run(
|
||||
() => archiveTemplate(draft.selected!.id).unwrap(),
|
||||
t('designer.archived', 'Design withdrawn'),
|
||||
)
|
||||
}
|
||||
onDelete={() =>
|
||||
run(
|
||||
() => deleteTemplate(draft.selected!.id).unwrap(),
|
||||
t('designer.deleted', 'Draft deleted'),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Placeholders */}
|
||||
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.variables', 'Placeholders')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('designer.variablesHint', 'Click to insert at the cursor.')}
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={480} type="hover">
|
||||
<Stack gap={4}>
|
||||
{variables.map((variable) => (
|
||||
<Tooltip key={variable.key} label={variable.label} position="left">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
justify="flex-start"
|
||||
disabled={!canEdit || isPublished}
|
||||
onClick={() => insertVariable(variable.key)}
|
||||
>
|
||||
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
<TemplateVariableList
|
||||
variables={variables}
|
||||
disabled={editingLocked}
|
||||
canvasMode={mode === 'canvas'}
|
||||
onInsert={draft.insertVariable}
|
||||
onAddBlock={(key) => draft.addBlock(key)}
|
||||
onAddTextBlock={() => draft.addBlock(null, 'Text')}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<NewVersionModal
|
||||
opened={newOpen}
|
||||
name={newName}
|
||||
onNameChange={setNewName}
|
||||
creating={creating}
|
||||
onClose={() => setNewOpen(false)}
|
||||
title={t('designer.newVersion', 'New version')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
||||
withAsterisk
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'designer.newHint',
|
||||
'Starts from the live design, or the built-in layout if this type has none.',
|
||||
)}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setNewOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={creating}
|
||||
disabled={!newName.trim()}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
setSelectedId(created.id);
|
||||
setNewOpen(false);
|
||||
}, t('designer.created', 'Draft created'))
|
||||
}
|
||||
>
|
||||
{t('designer.create', 'Create')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
onCreate={() =>
|
||||
run(async () => {
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
draft.setSelectedId(created.id);
|
||||
setNewOpen(false);
|
||||
}, t('designer.created', 'Draft created'))
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Badge, Group, Paper, Stack, Table, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized, type LicenseType } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
licenseType: LicenseType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The STCW identity of a certificate type — regulation, level, and the
|
||||
* function and capacity rows a Certificate of Competency prints.
|
||||
*
|
||||
* Read-only for now: these are seeded configuration, and editing them safely
|
||||
* needs the approval workflow the designer guide describes. Showing them
|
||||
* matters regardless — an officer configuring fees or documents has no other
|
||||
* way to see what the certificate will actually claim.
|
||||
*/
|
||||
export function StcwMappingPanel({ licenseType }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
if (!licenseType.stcwControlled && !licenseType.certificateCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const functions = licenseType.stcwFunctions ?? [];
|
||||
const capacities = licenseType.stcwCapacities ?? [];
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{t('configuration.stcw.title', 'STCW mapping')}
|
||||
</Text>
|
||||
{licenseType.certificateCategory && (
|
||||
<Badge size="sm" variant="light">
|
||||
{licenseType.certificateCategory}
|
||||
</Badge>
|
||||
)}
|
||||
{licenseType.stcwControlled && (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{t('configuration.stcw.controlled', 'STCW controlled')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xl">
|
||||
<Field
|
||||
label={t('configuration.stcw.regulation', 'Regulation')}
|
||||
value={licenseType.stcwRegulation}
|
||||
/>
|
||||
<Field
|
||||
label={t('configuration.stcw.codeSection', 'Code section')}
|
||||
value={licenseType.stcwCodeSection}
|
||||
/>
|
||||
<Field
|
||||
label={t('configuration.stcw.department', 'Department')}
|
||||
value={licenseType.stcwDepartment}
|
||||
/>
|
||||
<Field
|
||||
label={t('configuration.stcw.level', 'Level')}
|
||||
value={licenseType.competencyLevel}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{functions.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{t('configuration.stcw.functions', 'Functions')}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('configuration.stcw.function', 'Function')}</Table.Th>
|
||||
<Table.Th>{t('configuration.stcw.level', 'Level')}</Table.Th>
|
||||
<Table.Th>
|
||||
{t('configuration.stcw.limitation', 'Limitation')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{functions.map((row, i) => (
|
||||
<Table.Tr key={`${localized(row.function)}-${i}`}>
|
||||
<Table.Td>{localized(row.function)}</Table.Td>
|
||||
<Table.Td>{row.level}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.limitation
|
||||
? localized(row.limitation)
|
||||
: t('configuration.stcw.none', 'None')}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{capacities.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{t('configuration.stcw.capacities', 'Capacities')}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('configuration.stcw.capacity', 'Capacity')}</Table.Th>
|
||||
<Table.Th>
|
||||
{t('configuration.stcw.limitation', 'Limitation')}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{capacities.map((row, i) => (
|
||||
<Table.Tr key={`${localized(row.capacity)}-${i}`}>
|
||||
<Table.Td>{localized(row.capacity)}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.limitation
|
||||
? localized(row.limitation)
|
||||
: t('configuration.stcw.none', 'None')}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{licenseType.prerequisiteLicenseKeys?.length ? (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{t('configuration.stcw.prerequisites', 'Must already hold')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{licenseType.prerequisiteLicenseKeys.map((key) => (
|
||||
<Badge key={key} size="sm" variant="outline">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{value || '—'}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
applicantName: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (payload: {
|
||||
examId: string;
|
||||
admissionNumber?: string;
|
||||
examDate?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a candidate who has paid the examination fee into an existing sitting.
|
||||
*
|
||||
* Sessions are picked from the exam calendar rather than typed, because the
|
||||
* candidate joins a scheduled sitting — this is an assignment, not the creation
|
||||
* of a per-candidate appointment.
|
||||
*/
|
||||
export function ScheduleExamModal({
|
||||
opened,
|
||||
applicantName,
|
||||
loading,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened });
|
||||
const [examId, setExamId] = useState<string | null>(null);
|
||||
const [admissionNumber, setAdmissionNumber] = useState('');
|
||||
|
||||
const options = (exams?.items ?? []).map((exam) => ({
|
||||
value: exam.id,
|
||||
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
}));
|
||||
const selected = exams?.items?.find((exam) => exam.id === examId);
|
||||
|
||||
function confirm() {
|
||||
if (!examId) return;
|
||||
onConfirm({
|
||||
examId,
|
||||
admissionNumber: admissionNumber.trim() || undefined,
|
||||
examDate: selected?.date ? String(selected.date) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('review.actions.scheduleExam', 'Schedule exam')}
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('review.scheduleExam.intro', {
|
||||
defaultValue:
|
||||
'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.',
|
||||
applicant: applicantName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{!isLoading && options.length === 0 ? (
|
||||
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
|
||||
{t(
|
||||
'review.scheduleExam.noSessions',
|
||||
'No exam sessions exist yet. Create one in the Exams area first.',
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Select
|
||||
label={t('review.scheduleExam.session', 'Exam session')}
|
||||
placeholder={t('review.scheduleExam.pick', 'Choose a sitting')}
|
||||
data={options}
|
||||
value={examId}
|
||||
onChange={setExamId}
|
||||
disabled={isLoading}
|
||||
searchable
|
||||
withAsterisk
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('review.scheduleExam.admissionNumber', 'Admission number')}
|
||||
description={t(
|
||||
'review.scheduleExam.admissionHint',
|
||||
'Leave blank to let the system issue one.',
|
||||
)}
|
||||
value={admissionNumber}
|
||||
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={loading} disabled={!examId} onClick={confirm}>
|
||||
{t('review.scheduleExam.confirm', 'Schedule')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type ActionId =
|
||||
| 'final-approve'
|
||||
| 'request-adjustment'
|
||||
| 'reject'
|
||||
| 'schedule-exam'
|
||||
| 'confirm-payment'
|
||||
| 'print'
|
||||
| 'copy-link'
|
||||
@@ -190,6 +191,17 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
requiresReason: true,
|
||||
irreversible: true,
|
||||
},
|
||||
{
|
||||
id: 'schedule-exam',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.scheduleExam',
|
||||
// Only after the examination fee clears — scheduling an unpaid candidate
|
||||
// is what the EXAM_PAID gate exists to prevent.
|
||||
from: ['EXAM_PAID'],
|
||||
permissions: ['can:schedule:exam-candidate'],
|
||||
emphasis: 'filled',
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
id: 'confirm-payment',
|
||||
tier: 'primary',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconRubberStamp,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
@@ -112,15 +113,43 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A seafarer certificate is judged on the person, not a company.
|
||||
*
|
||||
* Matched by prefix rather than listed: the catalogue holds over fifty
|
||||
* rank-specific CoC/CoP keys and grows whenever EMA configures another, and an
|
||||
* explicit map would silently fall back to the company presentation — showing
|
||||
* a reviewing officer capital, staff-role and inspection tabs that a
|
||||
* certificate application can never fill.
|
||||
*/
|
||||
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
|
||||
const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents'];
|
||||
|
||||
function isSeafarerCertificate(key: string): boolean {
|
||||
return (
|
||||
CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
|
||||
key === 'CERTIFICATE_OF_COMPETENCY' ||
|
||||
key === 'CERTIFICATE_OF_PROFICIENCY'
|
||||
);
|
||||
}
|
||||
|
||||
/** Falls back to a generic presentation so an unseeded type still renders. */
|
||||
export function presentationFor(key: string | undefined): LicenseTypePresentation {
|
||||
return (
|
||||
(key && PRESENTATION[key]) || {
|
||||
key: key ?? 'UNKNOWN',
|
||||
icon: IconFileDescription,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
}
|
||||
);
|
||||
if (key && PRESENTATION[key]) return PRESENTATION[key];
|
||||
|
||||
if (key && isSeafarerCertificate(key)) {
|
||||
return {
|
||||
key,
|
||||
icon: IconShieldCheck,
|
||||
detailSections: CERTIFICATE_SECTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: key ?? 'UNKNOWN',
|
||||
icon: IconFileDescription,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION);
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
useAssignApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleExamMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
} from '../../components/DecisionConfirmModal';
|
||||
import { ActivityRail } from '../../components/ActivityRail';
|
||||
import { DocumentsTab } from '../../components/DocumentsTab';
|
||||
import { ScheduleExamModal } from '../../components/ScheduleExamModal';
|
||||
import { computeSla } from '../../sla';
|
||||
import { reviewStaffColumns } from './columns';
|
||||
import { evaluateEligibility, presentationFor } from '../../config/license-types';
|
||||
@@ -152,6 +154,7 @@ export function LicenseReviewPage() {
|
||||
const [scheduleInspection] = useScheduleInspectionMutation();
|
||||
const [recordResult] = useRecordInspectionResultMutation();
|
||||
const [confirmPayment] = useConfirmPaymentMutation();
|
||||
const [scheduleExam, { isLoading: schedulingExam }] = useScheduleExamMutation();
|
||||
const [holdApplication] = useHoldApplicationMutation();
|
||||
const [resumeApplication] = useResumeApplicationMutation();
|
||||
const [escalateApplication] = useEscalateApplicationMutation();
|
||||
@@ -169,6 +172,7 @@ export function LicenseReviewPage() {
|
||||
const [inspectionOpen, setInspectionOpen] = useState(false);
|
||||
const [inspectionDate, setInspectionDate] = useState('');
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||
const [findings, setFindings] = useState('');
|
||||
const [checklist, setChecklist] = useState<
|
||||
Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>
|
||||
@@ -329,6 +333,11 @@ export function LicenseReviewPage() {
|
||||
case 'record-inspection':
|
||||
setResultOpen(true);
|
||||
return;
|
||||
// Needs a session picked before anything is sent, so it opens its own
|
||||
// modal rather than going through the generic confirm step.
|
||||
case 'schedule-exam':
|
||||
setScheduleExamOpen(true);
|
||||
return;
|
||||
case 'copy-link':
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
notifications.show({
|
||||
@@ -905,6 +914,30 @@ export function LicenseReviewPage() {
|
||||
onConfirm={submitDecision}
|
||||
/>
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
|
||||
loading={schedulingExam}
|
||||
onClose={() => setScheduleExamOpen(false)}
|
||||
onConfirm={async (payload) => {
|
||||
try {
|
||||
await scheduleExam({ id, ...payload }).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('review.done.scheduleExam', 'Exam scheduled'),
|
||||
message: '',
|
||||
});
|
||||
setScheduleExamOpen(false);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('review.actionFailed', 'Action failed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={inspectionOpen}
|
||||
onClose={() => setInspectionOpen(false)}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconEye,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
interface Seafarer {
|
||||
id: string;
|
||||
name: string;
|
||||
nationality: string;
|
||||
dob: string;
|
||||
rank: string;
|
||||
seamanBookNo: string;
|
||||
seamanBookExpiry: string;
|
||||
btcNo: string | null;
|
||||
bsidNo: string | null;
|
||||
medicalExpiry: string;
|
||||
medicalStatus: 'Valid' | 'Expiring' | 'Expired';
|
||||
cocCerts: { type: string; no: string; expiry: string }[];
|
||||
status: 'Active' | 'Inactive' | 'Suspended';
|
||||
}
|
||||
|
||||
|
||||
const RANK_OPTIONS = ['All', 'Master', 'Chief Engineer', 'Officer of the Watch', 'Able Seaman', 'Deck Rating'];
|
||||
const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
|
||||
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
|
||||
|
||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
|
||||
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
||||
if (!sf) return null;
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} — {sf.id}</Text></Group>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Medical Expiry</Text>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
|
||||
<Table fz="xs" verticalSpacing="xs">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{sf.cocCerts.map((c) => (
|
||||
<Table.Tr key={c.no}>
|
||||
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeafarerRegistryPage() {
|
||||
// The register is served whole and filtered in the browser: the filters are
|
||||
// instant facets over a page-sized list, and a round trip per keystroke
|
||||
// would make the search feel slower than the data it is searching.
|
||||
const { data } = useApiQuery<{ total: number; items: Seafarer[] }>({
|
||||
url: '/seafarer-registry',
|
||||
method: 'GET',
|
||||
});
|
||||
const seafarers = data?.items ?? [];
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [rankFilter, setRankFilter] = useState('All');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
const [medicalFilter, setMedicalFilter] = useState('All');
|
||||
const [selected, setSelected] = useState<Seafarer | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = useState(true);
|
||||
|
||||
const filtered = seafarers.filter((sf) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || sf.name.toLowerCase().includes(q) || sf.id.toLowerCase().includes(q) || sf.seamanBookNo.toLowerCase().includes(q);
|
||||
const matchRank = rankFilter === 'All' || sf.rank === rankFilter;
|
||||
const matchStatus = statusFilter === 'All' || sf.status === statusFilter;
|
||||
const matchMed = medicalFilter === 'All' || sf.medicalStatus === medicalFilter;
|
||||
return matchSearch && matchRank && matchStatus && matchMed;
|
||||
});
|
||||
|
||||
const KPI = [
|
||||
{ label: 'Total Seafarers', value: seafarers.length, color: 'blue', icon: IconUsers },
|
||||
{ label: 'Active', value: seafarers.filter((s) => s.status === 'Active').length, color: 'teal', icon: IconUser },
|
||||
{ label: 'Medical Expiring',value: seafarers.filter((s) => s.medicalStatus === 'Expiring').length, color: 'orange', icon: IconHeart },
|
||||
{ label: 'Medical Expired', value: seafarers.filter((s) => s.medicalStatus === 'Expired').length, color: 'red', icon: IconHeart },
|
||||
{ label: 'With CoC', value: seafarers.filter((s) => s.cocCerts.length > 0).length, color: 'violet', icon: IconShieldCheck },
|
||||
{ label: 'Without BSID', value: seafarers.filter((s) => !s.bsidNo).length, color: 'yellow', icon: IconId },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registry</Title>
|
||||
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
|
||||
{KPI.map((k) => {
|
||||
const KIcon = k.icon;
|
||||
return (
|
||||
<Card key={k.label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size={28} radius="sm" color={k.color} variant="light"><KIcon size={14} /></ThemeIcon>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fz="lg" fw={800} lh={1}>{k.value}</Text>
|
||||
<Text fz="xs" c="dimmed" lh={1.2} style={{ lineHeight: 1.2 }}>{k.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Search + filters */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group mb="sm" gap="sm" justify="space-between">
|
||||
<TextInput
|
||||
placeholder="Search by name, seafarer ID, or seaman book…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
rightSection={filtersOpen ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Collapse in={filtersOpen}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm" mb="md">
|
||||
<Select label="Rank" data={RANK_OPTIONS} value={rankFilter} onChange={(v) => setRankFilter(v ?? 'All')} size="sm" />
|
||||
<Select label="Status" data={STATUS_OPTIONS} value={statusFilter} onChange={(v) => setStatusFilter(v ?? 'All')} size="sm" />
|
||||
<Select label="Medical Status" data={MEDICAL_OPTIONS} value={medicalFilter} onChange={(v) => setMedicalFilter(v ?? 'All')} size="sm" />
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
</Collapse>
|
||||
|
||||
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
|
||||
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Seafarer ID', 'Name', 'Rank', 'Seaman Book', 'BTC', 'BSID', 'Medical', 'CoC/CoP', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((sf) => (
|
||||
<Table.Tr key={sf.id}>
|
||||
<Table.Td><Text fz="xs" fw={600} c="blue.7">{sf.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" fw={600}>{sf.name}</Text><Text fz="xs" c="dimmed">{sf.dob}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{sf.rank}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<IconBook2 size={11} color="var(--mantine-color-blue-6)" />
|
||||
<Text fz="xs">{sf.seamanBookNo}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.btcNo
|
||||
? <Group gap={4}><IconCertificate size={11} color="var(--mantine-color-teal-6)" /><Text fz="xs">{sf.btcNo}</Text></Group>
|
||||
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.bsidNo
|
||||
? <Group gap={4}><IconId size={11} color="var(--mantine-color-violet-6)" /><Text fz="xs">{sf.bsidNo}</Text></Group>
|
||||
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalStatus}</Badge>
|
||||
<Text fz="xs" c="dimmed">{sf.medicalExpiry}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.cocCerts.length > 0
|
||||
? <Badge color="violet" variant="light" size="xs">{sf.cocCerts.length} cert(s)</Badge>
|
||||
: <Text fz="xs" c="dimmed">—</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
|
||||
<IconEye size={13} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Text c="dimmed" fz="sm">No seafarers match your search.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Button, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import type { ProfileRow } from './columns';
|
||||
|
||||
export function seafarerStatusActionColumn(
|
||||
t: TFunction,
|
||||
handlers: { onStatus: (profile: ProfileRow) => void },
|
||||
): AdvancedColumn<ProfileRow> {
|
||||
return {
|
||||
header: '',
|
||||
label: t('seafarerRegistry.columns.actions', 'Actions'),
|
||||
cell: ({ row }) =>
|
||||
row.original.seafarerNumber ? (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.MANAGE_SEAFARER_STATUS]}
|
||||
hideOnly
|
||||
>
|
||||
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => handlers.onStatus(row.original)}
|
||||
>
|
||||
{t('seafarerRegistry.statusAction', 'Status')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</RequirePermission>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
|
||||
export interface ProfileRow {
|
||||
id: string;
|
||||
firstName: string;
|
||||
middleName?: string;
|
||||
lastName: string;
|
||||
gender?: string;
|
||||
type?: string;
|
||||
isComplete?: boolean;
|
||||
seafarerNumber?: string | null;
|
||||
seafarerStatus?: string | null;
|
||||
seafarerDepartment?: string | null;
|
||||
seafarerStatusReason?: string | null;
|
||||
profession?: { name?: { en?: string } };
|
||||
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
|
||||
}
|
||||
|
||||
export const SEAFARER_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
PENDING: 'yellow',
|
||||
SUSPENDED: 'orange',
|
||||
INACTIVE: 'gray',
|
||||
};
|
||||
|
||||
export const DEPARTMENT_LABELS: Record<string, string> = {
|
||||
DECK: 'Deck',
|
||||
ENGINE: 'Engine',
|
||||
CATERING: 'Catering',
|
||||
};
|
||||
|
||||
export function seafarerRegistryColumns(
|
||||
t: TFunction,
|
||||
handlers: { onDetail: (profile: ProfileRow) => void },
|
||||
): AdvancedColumn<ProfileRow>[] {
|
||||
return [
|
||||
{
|
||||
header: t('seafarerRegistry.columns.name', 'Name'),
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handlers.onDetail(row.original)}
|
||||
>
|
||||
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.number', 'Seafarer №'),
|
||||
cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.department', 'Department'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.seafarerDepartment
|
||||
? t(
|
||||
`seafarerRegistry.departments.${row.original.seafarerDepartment}`,
|
||||
DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment,
|
||||
)
|
||||
: '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.idNumber', 'ID number'),
|
||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.phone', 'Phone'),
|
||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.status', 'Status'),
|
||||
cell: ({ row }) =>
|
||||
row.original.seafarerNumber ? (
|
||||
<Badge size="sm" variant="light" color={SEAFARER_STATUS_COLORS[row.original.seafarerStatus ?? ''] ?? 'gray'}>
|
||||
{t(`seafarerRegistry.status.${row.original.seafarerStatus}`, row.original.seafarerStatus ?? '')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
|
||||
{row.original.isComplete
|
||||
? t('seafarerRegistry.notRegistered', 'Not registered')
|
||||
: t('seafarerRegistry.incomplete', 'Incomplete')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconSearch,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useApiQuery,
|
||||
useGetMedicalForProfileQuery,
|
||||
useGetSeaServiceForProfileQuery,
|
||||
useUpdateSeafarerStatusMutation,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
DEPARTMENT_LABELS,
|
||||
SEAFARER_STATUS_COLORS,
|
||||
seafarerRegistryColumns,
|
||||
type ProfileRow,
|
||||
} from './columns';
|
||||
import { seafarerStatusActionColumn } from './actions';
|
||||
|
||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
/** The registered seafarer's records, read-only (verification is module 06). */
|
||||
function SeafarerDetailDrawer({
|
||||
profile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const profileId = profile?.id ?? '';
|
||||
const { data: seaService, isLoading: loadingSea } =
|
||||
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
||||
const { data: medical, isLoading: loadingMedical } =
|
||||
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size="lg"
|
||||
title={
|
||||
profile
|
||||
? [profile.firstName, profile.middleName, profile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{profile && (
|
||||
<Stack>
|
||||
<Group gap="xl">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{t('seafarerRegistry.drawer.seafarerNumber', 'Seafarer number')}
|
||||
</Text>
|
||||
<Text fw={700} ff="monospace">
|
||||
{profile.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{t('seafarerRegistry.drawer.department', 'Department')}
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{profile.seafarerDepartment
|
||||
? t(
|
||||
`seafarerRegistry.departments.${profile.seafarerDepartment}`,
|
||||
DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment,
|
||||
)
|
||||
: '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{t('seafarerRegistry.drawer.status', 'Status')}
|
||||
</Text>
|
||||
<Badge
|
||||
color={
|
||||
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
|
||||
}
|
||||
>
|
||||
{profile.seafarerStatus
|
||||
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
|
||||
: t('seafarerRegistry.drawer.notRegistered', 'NOT REGISTERED')}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
{profile.seafarerStatusReason && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('seafarerRegistry.drawer.statusReason', {
|
||||
reason: profile.seafarerStatusReason,
|
||||
defaultValue: 'Status reason: {{reason}}',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
||||
{t('seafarerRegistry.drawer.seaServiceTab', 'Sea Service')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
||||
{t('seafarerRegistry.drawer.medicalTab', 'Medical')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="sm">
|
||||
{loadingSea ? (
|
||||
<Loader size="sm" />
|
||||
) : (seaService ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('seafarerRegistry.drawer.noSeaService', 'No sea-service records.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.vessel', 'Vessel')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.rank', 'Rank')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.period', 'Period')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(seaService ?? []).map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('seafarerRegistry.drawer.imoPrefix', {
|
||||
number: record.imoNumber,
|
||||
defaultValue: 'IMO {{number}}',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>
|
||||
{showDate(record.engagementDate)} → {showDate(record.dischargeDate)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{t(`seafarerRegistry.recordStatus.${record.status}`, record.status)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="medical" pt="sm">
|
||||
{loadingMedical ? (
|
||||
<Loader size="sm" />
|
||||
) : (medical ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('seafarerRegistry.drawer.noMedical', 'No medical certificates.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.issuer', 'Issuer')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.validity', 'Validity')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.fitness', 'Fitness')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(medical ?? []).map((certificate) => (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>{certificate.issuerName}</Table.Td>
|
||||
<Table.Td>
|
||||
{showDate(certificate.issueDate)} → {showDate(certificate.expiryDate)}
|
||||
</Table.Td>
|
||||
<Table.Td>{certificate.fitnessStatus}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={RECORD_STATUS_COLORS[certificate.status]}
|
||||
>
|
||||
{t(`seafarerRegistry.recordStatus.${certificate.status}`, certificate.status)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** US-SEA-013: suspend / reinstate / close, always with a reason. */
|
||||
function StatusModal({
|
||||
profile,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
||||
|
||||
const submit = async () => {
|
||||
if (!profile || !status) return;
|
||||
try {
|
||||
await updateStatus({
|
||||
profileId: profile.id,
|
||||
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
||||
reason,
|
||||
}).unwrap();
|
||||
notify.success(t('seafarerRegistry.modal.updated', 'Seafarer status updated'));
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
extractErrorMessage(error, t('seafarerRegistry.modal.updateFailed', 'Could not update the status')),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
title={t('seafarerRegistry.modal.title', 'Change seafarer status')}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('seafarerRegistry.modal.body', {
|
||||
number: profile?.seafarerNumber,
|
||||
status: profile?.seafarerStatus
|
||||
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
|
||||
: profile?.seafarerStatus,
|
||||
defaultValue:
|
||||
'{{number}} — currently {{status}}. The reason is recorded and visible to the seafarer.',
|
||||
})}
|
||||
</Text>
|
||||
<Select
|
||||
label={t('seafarerRegistry.modal.newStatus', 'New status')}
|
||||
required
|
||||
data={[
|
||||
{ value: 'SUSPENDED', label: t('seafarerRegistry.modal.suspend', 'Suspend') },
|
||||
{ value: 'INACTIVE', label: t('seafarerRegistry.modal.close', 'Close') },
|
||||
{ value: 'ACTIVE', label: t('seafarerRegistry.modal.reinstate', 'Reinstate') },
|
||||
].filter((o) => o.value !== profile?.seafarerStatus)}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label={t('seafarerRegistry.modal.reason', 'Reason')}
|
||||
required
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('seafarerRegistry.modal.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
||||
disabled={!status || reason.trim().length < 3}
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
{t('seafarerRegistry.modal.confirm', 'Confirm')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered seafarer profiles, read from the real profiles endpoint.
|
||||
*
|
||||
* Registration review itself happens in the licence queue (the
|
||||
* SEAFARER_REGISTRATION application type); this page is the resulting
|
||||
* register — numbers, departments, statuses, and each seafarer's records.
|
||||
*/
|
||||
export function SeafarerRegistryPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
||||
const { data, isLoading, refetch } = useApiQuery<{
|
||||
total: number;
|
||||
items: ProfileRow[];
|
||||
}>({
|
||||
url: '/profiles',
|
||||
method: 'GET',
|
||||
params: { q: 'i=profession,address&t=200' },
|
||||
});
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
const items = (data?.items ?? []).filter((p) => {
|
||||
if (!search.trim()) return true;
|
||||
const term = search.toLowerCase();
|
||||
return [
|
||||
p.firstName,
|
||||
p.middleName,
|
||||
p.lastName,
|
||||
p.address?.idNumber,
|
||||
p.seafarerNumber,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term));
|
||||
});
|
||||
const page = paginate(items);
|
||||
|
||||
const columns = [
|
||||
...seafarerRegistryColumns(t, { onDetail: setDetail }),
|
||||
seafarerStatusActionColumn(t, { onStatus: setStatusTarget }),
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{t('seafarerRegistry.title', 'Seafarer registry')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('seafarerRegistry.profileCount', {
|
||||
count: data?.total ?? 0,
|
||||
defaultValue: '{{count}} profile(s)',
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder={t('seafarerRegistry.searchPlaceholder', 'Name, ID or seafarer number')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('seafarerRegistry.title', 'Seafarer registry')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isLoading}
|
||||
emptyText={
|
||||
search
|
||||
? t('seafarerRegistry.emptySearch', 'No profiles match that search.')
|
||||
: t('seafarerRegistry.emptyNone', 'No seafarers registered yet.')
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
|
||||
<StatusModal
|
||||
profile={statusTarget}
|
||||
onClose={() => setStatusTarget(null)}
|
||||
onDone={refetch}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeafarerRegistryPage;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { landingAm } from "@ema-platform/ui";
|
||||
import type { Translations } from "./en";
|
||||
|
||||
export const am: Translations = {
|
||||
landing: landingAm,
|
||||
|
||||
app: {
|
||||
name: "ኢማ አስተዳደር",
|
||||
shortName: "ኢማ",
|
||||
@@ -76,6 +79,7 @@ export const am: Translations = {
|
||||
dashboard: "ዳሽቦርድ",
|
||||
userManagement: "የተጠቃሚ አስተዳደር",
|
||||
seamanBookQueue: "የመርከበኞች መጽሐፍ ወረፋ",
|
||||
btcQueue: "የBTC ወረፋ",
|
||||
cocQueue: "የCoC ወረፋ",
|
||||
copQueue: "የCoP ወረፋ",
|
||||
endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",
|
||||
@@ -951,6 +955,7 @@ export const am: Translations = {
|
||||
finalApprove: "አጽድቅ እና ስጥ",
|
||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||
reject: "አትቀበል",
|
||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||
confirmPayment: "ክፍያ አረጋግጥ",
|
||||
print: "ሰነድ አትም",
|
||||
copyLink: "አገናኝ ቅዳ",
|
||||
@@ -993,6 +998,7 @@ export const am: Translations = {
|
||||
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
|
||||
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
||||
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
||||
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
|
||||
},
|
||||
notifications: {
|
||||
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",
|
||||
@@ -1301,4 +1307,68 @@ export const am: Translations = {
|
||||
view: "ይመልከቱ",
|
||||
noUrl: "URL የለም",
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: "ገጽታ ቀይር",
|
||||
brandTitle: "የባህር ትራንስፖርት ፈቃድ አሰጣጥ ቀላል ሆኗል።",
|
||||
brandSubtitle: "ለመርከብ እና ለመርከበኛ ፈቃዶች ያመልክቱ፣ ሰነዶችን ይስቀሉ፣ እና እያንዳንዱን ማመልከቻ በአንድ ደህንነቱ በተጠበቀ መተግበሪያ ላይ ይከታተሉ።",
|
||||
feature1: "ማመልከቻዎችን በመስመር ላይ 24/7 ያስገቡ",
|
||||
feature2: "የቅጽበት ሁኔታ ክትትል እና ማንቂያ",
|
||||
feature3: "በእንግሊዝኛ እና በአማርኛ ይገኛል",
|
||||
copyright: "© 2026 የኢትዮጵያ የባህር ትራንስፖርት ባለስልጣን",
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
|
||||
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
|
||||
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
|
||||
subtitle: "መለያዎን ለመድረስ ይግቡ።",
|
||||
emailOrPhoneLabel: "ኢሜይል ወይም ስልክ",
|
||||
emailOrPhonePlaceholder: "you@example.com",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "የይለፍ ቃልዎ",
|
||||
rememberMe: "አስታውሰኝ",
|
||||
forgotPassword: "የይለፍ ቃል ረሱ?",
|
||||
signIn: "ግባ",
|
||||
noAccount: "መለያ የለዎትም? ",
|
||||
createOne: "አንድ ይፍጠሩ",
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
|
||||
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
|
||||
phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
|
||||
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
|
||||
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
|
||||
brandTitle: "የ{{appName}} ማህበረሰብን ይቀላቀሉ።",
|
||||
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
|
||||
title: "መለያ ይፍጠሩ",
|
||||
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
|
||||
nameEnLabel: "ስም (እንግሊዝኛ)",
|
||||
nameEnPlaceholder: "አበበ በቀለ",
|
||||
nameAmLabel: "ስም (አማርኛ)",
|
||||
nameAmPlaceholder: "ስም",
|
||||
emailLabel: "ኢሜይል አድራሻ",
|
||||
emailPlaceholder: "you@example.com",
|
||||
usernameLabel: "የተጠቃሚ ስም",
|
||||
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
|
||||
phoneLabel: "ስልክ ቁጥር",
|
||||
phonePlaceholder: "+251 911 234 567",
|
||||
passwordLabel: "የይለፍ ቃል",
|
||||
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
|
||||
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
|
||||
confirmPasswordPlaceholder: "የይለፍ ቃል እንደገና ያስገቡ",
|
||||
agreeToThe: "እስማማለሁ ከ",
|
||||
termsAndPrivacy: "ውሎች እና የግላዊነት ፖሊሲ",
|
||||
createAccount: "መለያ ይፍጠሩ",
|
||||
haveAccount: "አስቀድሞ መለያ አለዎት? ",
|
||||
signIn: "ግባ",
|
||||
passwordRule: {
|
||||
minLength: "ቢያንስ {{min}} ቁምፊዎች",
|
||||
lowercase: "አንድ ትንሽ ፊደል",
|
||||
uppercase: "አንድ ትልቅ ፊደል",
|
||||
number: "አንድ ቁጥር",
|
||||
special: "አንድ ልዩ ምልክት",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { landingEn } from '@ema-platform/ui';
|
||||
|
||||
export const en = {
|
||||
landing: landingEn,
|
||||
|
||||
app: {
|
||||
name: 'EMA Admin',
|
||||
shortName: 'EMA',
|
||||
@@ -59,6 +63,7 @@ export const en = {
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
btcQueue: 'BTC Queue',
|
||||
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
||||
vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
|
||||
vesselRegistrationQueue: 'Vessel Register',
|
||||
@@ -952,6 +957,7 @@ export const en = {
|
||||
finalApprove: 'Approve & issue',
|
||||
requestAdjustment: 'Request adjustment',
|
||||
reject: 'Reject',
|
||||
scheduleExam: 'Schedule exam',
|
||||
confirmPayment: 'Confirm payment',
|
||||
print: 'Print dossier',
|
||||
copyLink: 'Copy link',
|
||||
@@ -993,6 +999,7 @@ export const en = {
|
||||
resume: 'Returns application {{number}} to the stage it was held from.',
|
||||
escalate: 'Raises application {{number}} to a supervisor for a decision.',
|
||||
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
||||
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
|
||||
},
|
||||
notifications: {
|
||||
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
||||
@@ -1297,6 +1304,70 @@ export const en = {
|
||||
view: 'View',
|
||||
noUrl: 'No URL',
|
||||
},
|
||||
|
||||
authShell: {
|
||||
toggleTheme: 'Toggle theme',
|
||||
brandTitle: 'Maritime licensing, made simple.',
|
||||
brandSubtitle: 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
feature1: 'Submit applications online, 24/7',
|
||||
feature2: 'Real-time status tracking & alerts',
|
||||
feature3: 'Available in English & አማርኛ',
|
||||
copyright: '© 2026 Ethiopian Maritime Authority',
|
||||
},
|
||||
|
||||
login: {
|
||||
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
|
||||
passwordMinLength: 'Password must be at least 8 characters',
|
||||
welcome: 'Welcome to {{appName}}',
|
||||
subtitle: 'Sign in to access your account.',
|
||||
emailOrPhoneLabel: 'Email or phone',
|
||||
emailOrPhonePlaceholder: 'you@example.com',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'Your password',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPassword: 'Forgot password?',
|
||||
signIn: 'Sign in',
|
||||
noAccount: "Don't have an account? ",
|
||||
createOne: 'Create one',
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: 'Username must be at least 3 characters',
|
||||
nameEnRequired: 'Name (English) is required',
|
||||
phoneRequired: 'Phone number is required',
|
||||
confirmPasswordRequired: 'Confirm your password',
|
||||
passwordsDontMatch: 'Passwords do not match',
|
||||
brandTitle: "Join {{appName}}'s community.",
|
||||
brandSubtitle: 'Create your account to access {{appName}} features.',
|
||||
title: 'Create account',
|
||||
subtitle: 'It only takes a minute to get started.',
|
||||
nameEnLabel: 'Name (English)',
|
||||
nameEnPlaceholder: 'Abebe Bekele',
|
||||
nameAmLabel: 'Name (Amharic)',
|
||||
nameAmPlaceholder: 'ስም',
|
||||
emailLabel: 'Email address',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
usernameLabel: 'Username',
|
||||
usernamePlaceholder: 'Choose a username',
|
||||
phoneLabel: 'Phone number',
|
||||
phonePlaceholder: '+251 911 234 567',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
confirmPasswordLabel: 'Confirm password',
|
||||
confirmPasswordPlaceholder: 'Re-enter password',
|
||||
agreeToThe: 'I agree to the ',
|
||||
termsAndPrivacy: 'Terms & Privacy Policy',
|
||||
createAccount: 'Create account',
|
||||
haveAccount: 'Already have an account? ',
|
||||
signIn: 'Sign in',
|
||||
passwordRule: {
|
||||
minLength: 'At least {{min}} characters',
|
||||
lowercase: 'One lowercase letter',
|
||||
uppercase: 'One uppercase letter',
|
||||
number: 'One number',
|
||||
special: 'One special character',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -35,7 +35,16 @@ export function BackofficeLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { can } = usePermissions();
|
||||
const { permissions: granted, known } = usePermissions();
|
||||
|
||||
// TEMPORARY diagnostic — remove once the sidebar is confirmed working.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'[NAV] known=', known,
|
||||
'granted=', granted.length,
|
||||
'| cookies:', document.cookie.split('; ').map((c) => c.split('=')[0]).filter((n) => n.includes('token')),
|
||||
'| token tail:', (document.cookie.match(/ema-backoffice-auth-token=([^;]+)/)?.[1] ?? 'NONE').slice(-12),
|
||||
);
|
||||
|
||||
// Badges reflect real pending work. One grouped request on a timer, shared
|
||||
// by the sidebar and the top bar via the RTK cache.
|
||||
@@ -53,17 +62,10 @@ export function BackofficeLayout() {
|
||||
: item,
|
||||
),
|
||||
}));
|
||||
return filterByPermissions(
|
||||
withBadges,
|
||||
// `can` already fails open when the token carries no permission claim,
|
||||
// so this only ever removes items we are sure the user cannot use.
|
||||
withBadges
|
||||
.flatMap((section) => section.items)
|
||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||
.flatMap((item) => item.permissions ?? [])
|
||||
.filter((permission) => can([permission])),
|
||||
);
|
||||
}, [counts?.unassigned, can]);
|
||||
// Unfiltered until the grant list has loaded, matching PortalLayout and
|
||||
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
||||
return known ? filterByPermissions(withBadges, granted) : withBadges;
|
||||
}, [counts?.unassigned, granted, known]);
|
||||
|
||||
/** Flat list used for breadcrumbs and active-route lookup. */
|
||||
const navItems = useMemo<NavItem[]>(
|
||||
|
||||
@@ -104,7 +104,8 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/SEAMAN_BOOK', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/BTC_BASIC_TRAINING', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
|
||||
13
apps/backoffice/src/app/router/LandingRoute.tsx
Normal file
13
apps/backoffice/src/app/router/LandingRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { LandingPage } from '@ema-platform/ui';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* Public `/` — mounts the shared landing page. Backoffice has no /signup
|
||||
* (enableSignup: false) and no /verify route, so those props are omitted.
|
||||
*/
|
||||
export function LandingRoute() {
|
||||
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
||||
|
||||
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} />;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { LandingRoute } from './LandingRoute';
|
||||
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import UserManagementPage from '../features/user-management/UserManagementPage';
|
||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
@@ -61,7 +62,7 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementPage /> },
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/', element: <LandingRoute /> },
|
||||
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
@@ -69,7 +70,6 @@ const router = createBrowserRouter([
|
||||
{
|
||||
element: <BackofficeLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
|
||||
{ path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi, configureTokenRefresh } from '@ema-platform/api';
|
||||
import {
|
||||
baseApi,
|
||||
configureSessionScope,
|
||||
configureTokenRefresh,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
authReducer,
|
||||
signupReducer,
|
||||
@@ -13,6 +17,9 @@ import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
||||
import { preferencesReducer } from './preferences.slice';
|
||||
|
||||
configureAuthStorage('ema-backoffice', true);
|
||||
// Cookies are shared across ports on localhost, so the API layer must be told
|
||||
// which app it belongs to — otherwise it reads the portal's token.
|
||||
configureSessionScope('ema-backoffice');
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
|
||||
Reference in New Issue
Block a user