mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Button,
|
||||||
ColorInput,
|
ColorInput,
|
||||||
Group,
|
Group,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconTrash } from '@tabler/icons-react';
|
import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api';
|
import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api';
|
||||||
|
|
||||||
@@ -43,7 +44,17 @@ export function BlockPropertiesPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLiteral = block.variable === null;
|
const current = block;
|
||||||
|
const isLiteral = current.variable === null;
|
||||||
|
const isImage = current.type === 'image';
|
||||||
|
const variableByKey = new Map(variables.map((v) => [v.key, v]));
|
||||||
|
|
||||||
|
const selectVariable = (key: string | null) => {
|
||||||
|
if (!key) return;
|
||||||
|
const kind: 'text' | 'image' =
|
||||||
|
variableByKey.get(key)?.kind === 'image' ? 'image' : 'text';
|
||||||
|
onChange({ ...current, variable: key, type: kind });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
@@ -70,11 +81,9 @@ export function BlockPropertiesPanel({
|
|||||||
value={isLiteral ? 'text' : 'variable'}
|
value={isLiteral ? 'text' : 'variable'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
onChange(
|
value === 'text'
|
||||||
value === 'text'
|
? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' })
|
||||||
? { ...block, variable: null, text: block.text ?? '' }
|
: selectVariable(variables[0]?.key ?? 'companyName')
|
||||||
: { ...block, variable: variables[0]?.key ?? 'companyName' },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'variable', label: t('designer.blockVariable', 'Variable') },
|
{ value: 'variable', label: t('designer.blockVariable', 'Variable') },
|
||||||
@@ -94,24 +103,26 @@ export function BlockPropertiesPanel({
|
|||||||
label={t('designer.blockVariableLabel', 'Variable')}
|
label={t('designer.blockVariableLabel', 'Variable')}
|
||||||
data={variables.map((variable) => ({
|
data={variables.map((variable) => ({
|
||||||
value: variable.key,
|
value: variable.key,
|
||||||
label: variable.label,
|
label: variable.kind === 'image' ? `🖼 ${variable.label}` : variable.label,
|
||||||
}))}
|
}))}
|
||||||
value={block.variable}
|
value={block.variable}
|
||||||
onChange={(value) => onChange({ ...block, variable: value })}
|
onChange={selectVariable}
|
||||||
searchable
|
searchable
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group gap="xs" grow>
|
<Group gap="xs" grow>
|
||||||
<NumberInput
|
{!isImage && (
|
||||||
label={t('designer.blockFontSize', 'Font size')}
|
<NumberInput
|
||||||
value={block.fontSize ?? 14}
|
label={t('designer.blockFontSize', 'Font size')}
|
||||||
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
|
value={block.fontSize ?? 14}
|
||||||
min={4}
|
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
|
||||||
max={200}
|
min={4}
|
||||||
disabled={disabled}
|
max={200}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t('designer.blockWidth', 'Width (%)')}
|
label={t('designer.blockWidth', 'Width (%)')}
|
||||||
value={block.widthPct}
|
value={block.widthPct}
|
||||||
@@ -149,42 +160,62 @@ export function BlockPropertiesPanel({
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SegmentedControl
|
{!isImage && (
|
||||||
fullWidth
|
<>
|
||||||
size="xs"
|
<SegmentedControl
|
||||||
value={block.align ?? 'left'}
|
fullWidth
|
||||||
disabled={disabled}
|
size="xs"
|
||||||
onChange={(value) =>
|
value={block.align ?? 'left'}
|
||||||
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
|
disabled={disabled}
|
||||||
}
|
onChange={(value) =>
|
||||||
data={[
|
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
|
||||||
{ value: 'left', label: t('designer.alignLeft', 'Left') },
|
}
|
||||||
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
|
data={[
|
||||||
{ value: 'right', label: t('designer.alignRight', 'Right') },
|
{ value: 'left', label: t('designer.alignLeft', 'Left') },
|
||||||
]}
|
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
|
||||||
/>
|
{ value: 'right', label: t('designer.alignRight', 'Right') },
|
||||||
|
{ value: 'justify', label: t('designer.alignJustify', 'Justify') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<SegmentedControl
|
<Group gap="xs">
|
||||||
fullWidth
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
value={block.fontWeight ?? 'normal'}
|
variant={block.fontWeight === 'bold' ? 'filled' : 'default'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(value) =>
|
onClick={() =>
|
||||||
onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] })
|
onChange({
|
||||||
}
|
...block,
|
||||||
data={[
|
fontWeight: block.fontWeight === 'bold' ? 'normal' : 'bold',
|
||||||
{ value: 'normal', label: t('designer.weightNormal', 'Normal') },
|
})
|
||||||
{ value: 'bold', label: t('designer.weightBold', 'Bold') },
|
}
|
||||||
]}
|
>
|
||||||
/>
|
<IconBold size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={block.fontStyle === 'italic' ? 'filled' : 'default'}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...block,
|
||||||
|
fontStyle: block.fontStyle === 'italic' ? 'normal' : 'italic',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconItalic size={14} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<ColorInput
|
<ColorInput
|
||||||
label={t('designer.blockColor', 'Colour')}
|
label={t('designer.blockColor', 'Colour')}
|
||||||
value={block.color ?? '#111111'}
|
value={block.color ?? '#111111'}
|
||||||
onChange={(value) => onChange({ ...block, color: value })}
|
onChange={(value) => onChange({ ...block, color: value })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
format="hex"
|
format="hex"
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
||||||
import { IconPlus } from '@tabler/icons-react';
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalized, type LicenseType } from '@ema-platform/api';
|
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
|
||||||
import { groupedTypeOptions } from '../config/designer';
|
import { groupedTypeOptions } from '../config/designer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
licenseTypes: LicenseType[];
|
licenseTypes: LicenseType[];
|
||||||
typeId: string | null;
|
typeId: string | null;
|
||||||
onTypeChange: (id: string | null) => void;
|
onTypeChange: (id: string | null) => void;
|
||||||
|
/** The selected licence type's rank ladder — empty for non-CoC/CoP types. */
|
||||||
|
ranks: Rank[];
|
||||||
|
rankId: string | null;
|
||||||
|
onRankChange: (id: string | null) => void;
|
||||||
validityMonths: number;
|
validityMonths: number;
|
||||||
onValidityChange: (months: number) => void;
|
onValidityChange: (months: number) => void;
|
||||||
currentValidityMonths?: number | null;
|
currentValidityMonths?: number | null;
|
||||||
@@ -22,6 +26,9 @@ export function DesignerToolbar({
|
|||||||
licenseTypes,
|
licenseTypes,
|
||||||
typeId,
|
typeId,
|
||||||
onTypeChange,
|
onTypeChange,
|
||||||
|
ranks,
|
||||||
|
rankId,
|
||||||
|
onRankChange,
|
||||||
validityMonths,
|
validityMonths,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
currentValidityMonths,
|
currentValidityMonths,
|
||||||
@@ -49,6 +56,23 @@ export function DesignerToolbar({
|
|||||||
w={340}
|
w={340}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* CoC/CoP only — a rank can carry its own design (e.g. Master's
|
||||||
|
certificate differs from an OOW's). "Default" (null) is the design
|
||||||
|
every other rank under the type falls back to. */}
|
||||||
|
{ranks.length > 0 && (
|
||||||
|
<Select
|
||||||
|
label={t('designer.rank', 'Rank')}
|
||||||
|
description={t('designer.rankHint', 'Leave as Default to design for every rank')}
|
||||||
|
data={[
|
||||||
|
{ value: '', label: t('designer.rankDefault', 'Default (all ranks)') },
|
||||||
|
...ranks.map((r) => ({ value: r.id, label: localized(r.name) })),
|
||||||
|
]}
|
||||||
|
value={rankId ?? ''}
|
||||||
|
onChange={(value) => onRankChange(value || null)}
|
||||||
|
w={220}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Validity lives beside the design because it is the other half of
|
{/* Validity lives beside the design because it is the other half of
|
||||||
what a certificate promises. */}
|
what a certificate promises. */}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Paper, Text } from '@mantine/core';
|
import { Box, Paper, Text } from '@mantine/core';
|
||||||
|
import { IconPhoto } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
|
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
|
||||||
|
|
||||||
@@ -233,6 +234,7 @@ export function TemplateCanvas({
|
|||||||
|
|
||||||
{placements.map((block) => {
|
{placements.map((block) => {
|
||||||
const isSelected = block.id === selectedId;
|
const isSelected = block.id === selectedId;
|
||||||
|
const isImage = block.type === 'image';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={block.id}
|
key={block.id}
|
||||||
@@ -247,6 +249,7 @@ export function TemplateCanvas({
|
|||||||
width: `${block.widthPct}%`,
|
width: `${block.widthPct}%`,
|
||||||
fontSize: block.fontSize ?? 14,
|
fontSize: block.fontSize ?? 14,
|
||||||
fontWeight: block.fontWeight ?? 'normal',
|
fontWeight: block.fontWeight ?? 'normal',
|
||||||
|
fontStyle: block.fontStyle ?? 'normal',
|
||||||
textAlign: block.align ?? 'left',
|
textAlign: block.align ?? 'left',
|
||||||
color: block.color ?? '#111111',
|
color: block.color ?? '#111111',
|
||||||
cursor: disabled ? 'default' : 'move',
|
cursor: disabled ? 'default' : 'move',
|
||||||
@@ -259,9 +262,30 @@ export function TemplateCanvas({
|
|||||||
lineHeight: 1.3,
|
lineHeight: 1.3,
|
||||||
wordWrap: 'break-word',
|
wordWrap: 'break-word',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
// An image block has no real image to show here — the actual
|
||||||
|
// data URI is only resolved server-side at render/preview
|
||||||
|
// time — so it gets a fixed square footprint and an icon
|
||||||
|
// instead of stretching to a text block's shape.
|
||||||
|
...(isImage
|
||||||
|
? {
|
||||||
|
aspectRatio: '1',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: isSelected
|
||||||
|
? 'rgba(34,139,230,0.08)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{block.variable ? `{{${block.variable}}}` : block.text || ' '}
|
{isImage ? (
|
||||||
|
<IconPhoto size={18} color="var(--mantine-color-gray-6)" />
|
||||||
|
) : block.variable ? (
|
||||||
|
`{{${block.variable}}}`
|
||||||
|
) : (
|
||||||
|
block.text || ' '
|
||||||
|
)}
|
||||||
|
|
||||||
{isSelected && !disabled && (
|
{isSelected && !disabled && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
|
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
|
||||||
import { IconPlus } from '@tabler/icons-react';
|
import { IconPhoto, IconPlus } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TemplateVariable } from '@ema-platform/api';
|
||||||
interface Variable {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variables: Variable[];
|
variables: TemplateVariable[];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
|
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
|
||||||
canvasMode: boolean;
|
canvasMode: boolean;
|
||||||
onInsert: (key: string) => void;
|
onInsert: (key: string) => void;
|
||||||
onAddBlock: (key: string) => void;
|
onAddBlock: (key: string, kind: 'text' | 'image') => void;
|
||||||
onAddTextBlock: () => void;
|
onAddTextBlock: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +56,13 @@ export function TemplateVariableList({
|
|||||||
variant="default"
|
variant="default"
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
leftSection={
|
||||||
|
variable.kind === 'image' ? <IconPhoto size={12} /> : undefined
|
||||||
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
canvasMode ? onAddBlock(variable.key) : onInsert(variable.key)
|
canvasMode
|
||||||
|
? onAddBlock(variable.key, variable.kind === 'image' ? 'image' : 'text')
|
||||||
|
: onInsert(variable.key)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
||||||
|
|||||||
@@ -41,14 +41,43 @@ function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string {
|
|||||||
return ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\n`;
|
return ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variable keys the renderer fills with a data URI — the fallback for a
|
||||||
|
* block placed before `type` existed on it. Keep in sync with
|
||||||
|
* `IMAGE_VARIABLE_KEYS` in the server's template-variables.ts; a new image
|
||||||
|
* variable added there should be added here too.
|
||||||
|
*/
|
||||||
|
const IMAGE_VARIABLE_KEYS = new Set([
|
||||||
|
'logo',
|
||||||
|
'holderPhoto',
|
||||||
|
'qrImage',
|
||||||
|
'sealImage',
|
||||||
|
'signatureImage',
|
||||||
|
'seafarerSignature',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isImageBlock(block: TemplateFieldPlacement): boolean {
|
||||||
|
if (block.type) return block.type === 'image';
|
||||||
|
return !!block.variable && IMAGE_VARIABLE_KEYS.has(block.variable);
|
||||||
|
}
|
||||||
|
|
||||||
function blockHtml(block: TemplateFieldPlacement): string {
|
function blockHtml(block: TemplateFieldPlacement): string {
|
||||||
const x = pct(block.xPct, 0);
|
const x = pct(block.xPct, 0);
|
||||||
const y = pct(block.yPct, 0);
|
const y = pct(block.yPct, 0);
|
||||||
// Minimum 1%, matching the server compiler: a zero-width block would render
|
// Minimum 1%, matching the server compiler: a zero-width block would render
|
||||||
// as an invisible sliver rather than as the mistake it is.
|
// as an invisible sliver rather than as the mistake it is.
|
||||||
const width = pct(block.widthPct, 30, 1);
|
const width = pct(block.widthPct, 30, 1);
|
||||||
|
|
||||||
|
if (isImageBlock(block) && block.variable) {
|
||||||
|
// Triple-brace: the value is a data URI, not markup — escaping it turns
|
||||||
|
// every "&" into "&" and corrupts the src.
|
||||||
|
const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;object-fit:contain;`;
|
||||||
|
return ` <img class="ema-block" style="${style}" src="{{{${block.variable}}}}" alt="" />\n`;
|
||||||
|
}
|
||||||
|
|
||||||
const size = block.fontSize ?? 14;
|
const size = block.fontSize ?? 14;
|
||||||
const weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
|
const weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
|
||||||
|
const style_ = block.fontStyle === 'italic' ? 'italic' : 'normal';
|
||||||
const align = block.align ?? 'left';
|
const align = block.align ?? 'left';
|
||||||
const color = escapeHtml(block.color ?? '#111111');
|
const color = escapeHtml(block.color ?? '#111111');
|
||||||
|
|
||||||
@@ -58,7 +87,7 @@ function blockHtml(block: TemplateFieldPlacement): string {
|
|||||||
|
|
||||||
const style =
|
const style =
|
||||||
`position:absolute;left:${x}%;top:${y}%;width:${width}%;` +
|
`position:absolute;left:${x}%;top:${y}%;width:${width}%;` +
|
||||||
`font-size:${size}px;font-weight:${weight};text-align:${align};color:${color};`;
|
`font-size:${size}px;font-weight:${weight};font-style:${style_};text-align:${align};color:${color};`;
|
||||||
|
|
||||||
return ` <div class="ema-block" style="${style}">${content}</div>\n`;
|
return ` <div class="ema-block" style="${style}">${content}</div>\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,23 +81,46 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
|||||||
const selectedBlock =
|
const selectedBlock =
|
||||||
placements.find((block) => block.id === selectedBlockId) ?? null;
|
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) => {
|
* Drops a new block near the top-left, where it is immediately visible.
|
||||||
const block: TemplateFieldPlacement = {
|
*
|
||||||
id: blockId(),
|
* An image block gets a square-ish default footprint instead of the text
|
||||||
variable,
|
* defaults (fontSize/color/align mean nothing on an `<img>`) — a seal or
|
||||||
text,
|
* signature dropped at 30% width and no explicit height would otherwise
|
||||||
xPct: 10,
|
* stretch to whatever the image's own aspect ratio makes of that width,
|
||||||
yPct: 10,
|
* which reads as broken until the author manually resizes it.
|
||||||
widthPct: 30,
|
*/
|
||||||
fontSize: 14,
|
const addBlock = useCallback(
|
||||||
fontWeight: 'normal',
|
(variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => {
|
||||||
align: 'left',
|
const block: TemplateFieldPlacement =
|
||||||
color: '#111111',
|
kind === 'image'
|
||||||
};
|
? {
|
||||||
setPlacements((prev) => [...prev, block]);
|
id: blockId(),
|
||||||
setSelectedBlockId(block.id);
|
variable,
|
||||||
}, []);
|
type: 'image',
|
||||||
|
xPct: 10,
|
||||||
|
yPct: 10,
|
||||||
|
widthPct: 15,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: blockId(),
|
||||||
|
variable,
|
||||||
|
text,
|
||||||
|
type: 'text',
|
||||||
|
xPct: 10,
|
||||||
|
yPct: 10,
|
||||||
|
widthPct: 30,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
align: 'left',
|
||||||
|
color: '#111111',
|
||||||
|
};
|
||||||
|
setPlacements((prev) => [...prev, block]);
|
||||||
|
setSelectedBlockId(block.id);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const updateBlock = useCallback((next: TemplateFieldPlacement) => {
|
const updateBlock = useCallback((next: TemplateFieldPlacement) => {
|
||||||
setPlacements((prev) =>
|
setPlacements((prev) =>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
useGetBuiltInTemplateQuery,
|
useGetBuiltInTemplateQuery,
|
||||||
useGetLicenseTemplatesQuery,
|
useGetLicenseTemplatesQuery,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
|
useGetRanksQuery,
|
||||||
useGetTemplateVariablesQuery,
|
useGetTemplateVariablesQuery,
|
||||||
usePublishLicenseTemplateMutation,
|
usePublishLicenseTemplateMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
@@ -66,14 +67,19 @@ export function CertificateDesignerPage() {
|
|||||||
|
|
||||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||||
const [typeId, setTypeId] = useState<string | null>(null);
|
const [typeId, setTypeId] = useState<string | null>(null);
|
||||||
|
const [rankId, setRankId] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: templates = [],
|
data: allTemplates = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
|
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
|
||||||
|
// The list is per licence type; a rank-specific design and the type's
|
||||||
|
// default both come back, so the version list is scoped to whichever the
|
||||||
|
// toolbar has selected.
|
||||||
|
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
|
||||||
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
||||||
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
||||||
|
|
||||||
@@ -95,6 +101,27 @@ export function CertificateDesignerPage() {
|
|||||||
|
|
||||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||||
|
|
||||||
|
// A rank ladder only exists for CoC/CoP — every other licence type designs
|
||||||
|
// one certificate for everyone who holds it. Keyed on `key`, not
|
||||||
|
// `certificateCategory`: that STCW-mapping column is unset on the seeded
|
||||||
|
// CoC/CoP rows (it's authored later, per StcwMappingPanel), while `key` is
|
||||||
|
// the stable identity CertificateEligibilityService itself branches on.
|
||||||
|
// CoC/CoP are each a single LicenseType spanning every department's ladder
|
||||||
|
// (the applicant's own department, not the type, decides which ladder they
|
||||||
|
// climb), so the picker offers every rank in the ladder across all
|
||||||
|
// departments rather than one department's.
|
||||||
|
const rankCategory: 'COC' | 'COP' | null =
|
||||||
|
selectedType?.key === 'CERTIFICATE_OF_COMPETENCY'
|
||||||
|
? 'COC'
|
||||||
|
: selectedType?.key === 'CERTIFICATE_OF_PROFICIENCY'
|
||||||
|
? 'COP'
|
||||||
|
: null;
|
||||||
|
const isRankScoped = rankCategory !== null;
|
||||||
|
const { data: allRanks } = useGetRanksQuery(undefined, { skip: !isRankScoped });
|
||||||
|
const ranks = (allRanks?.items ?? [])
|
||||||
|
.filter((r) => r.certificateCategory === rankCategory)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
// Default to the first licence type so the page is never an empty shell.
|
// Default to the first licence type so the page is never an empty shell.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
||||||
@@ -104,6 +131,12 @@ export function CertificateDesignerPage() {
|
|||||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||||
}, [selectedType]);
|
}, [selectedType]);
|
||||||
|
|
||||||
|
// Switching licence type leaves a stale rank selected from the previous
|
||||||
|
// type's ladder — reset to the type's default design.
|
||||||
|
useEffect(() => {
|
||||||
|
setRankId(null);
|
||||||
|
}, [typeId]);
|
||||||
|
|
||||||
function startNewVersion() {
|
function startNewVersion() {
|
||||||
setNewName(
|
setNewName(
|
||||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||||
@@ -130,6 +163,12 @@ export function CertificateDesignerPage() {
|
|||||||
setTypeId(value);
|
setTypeId(value);
|
||||||
draft.setSelectedId(null);
|
draft.setSelectedId(null);
|
||||||
}}
|
}}
|
||||||
|
ranks={ranks}
|
||||||
|
rankId={rankId}
|
||||||
|
onRankChange={(value) => {
|
||||||
|
setRankId(value);
|
||||||
|
draft.setSelectedId(null);
|
||||||
|
}}
|
||||||
validityMonths={validityMonths}
|
validityMonths={validityMonths}
|
||||||
onValidityChange={setValidityMonths}
|
onValidityChange={setValidityMonths}
|
||||||
currentValidityMonths={selectedType?.validityMonths}
|
currentValidityMonths={selectedType?.validityMonths}
|
||||||
@@ -353,7 +392,7 @@ export function CertificateDesignerPage() {
|
|||||||
disabled={editingLocked}
|
disabled={editingLocked}
|
||||||
canvasMode={mode === 'canvas'}
|
canvasMode={mode === 'canvas'}
|
||||||
onInsert={draft.insertVariable}
|
onInsert={draft.insertVariable}
|
||||||
onAddBlock={(key) => draft.addBlock(key)}
|
onAddBlock={(key, kind) => draft.addBlock(key, undefined, kind)}
|
||||||
onAddTextBlock={() => draft.addBlock(null, 'Text')}
|
onAddTextBlock={() => draft.addBlock(null, 'Text')}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -369,6 +408,7 @@ export function CertificateDesignerPage() {
|
|||||||
run(async () => {
|
run(async () => {
|
||||||
const created = await createTemplate({
|
const created = await createTemplate({
|
||||||
licenseTypeId: typeId as string,
|
licenseTypeId: typeId as string,
|
||||||
|
rankId,
|
||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
|
import { ActionIcon, Autocomplete, Checkbox, Group, Paper, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
|
||||||
|
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
|
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
|
||||||
import { useLocalized } from '@ema-platform/api';
|
import { useLocalized } from '@ema-platform/api';
|
||||||
@@ -7,6 +8,9 @@ import type { ConditionTarget } from '../config/schema-paths';
|
|||||||
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
|
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
|
||||||
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
|
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
|
||||||
|
|
||||||
|
/** One editable `anyOf` arm — a single-field condition, same shape a plain condition holds. */
|
||||||
|
type ConditionArm = Omit<ConditionValue, 'anyOf' | 'previousDocExpired'>;
|
||||||
|
|
||||||
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
|
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
|
||||||
|
|
||||||
function operatorOf(condition: ConditionValue | undefined): Operator | null {
|
function operatorOf(condition: ConditionValue | undefined): Operator | null {
|
||||||
@@ -29,6 +33,196 @@ function coerce(raw: string, targetType: string | undefined): string | number |
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-line summary of a condition for read-only chips ("when X = Y", "when X = Y or W = Z"). */
|
||||||
|
export function describeCondition(
|
||||||
|
condition: ConditionValue,
|
||||||
|
t: (key: string, fallback: string) => string,
|
||||||
|
): string {
|
||||||
|
if (condition.anyOf?.length) {
|
||||||
|
return condition.anyOf.map((arm) => describeCondition(arm, t)).join(` ${t('certReq.condition.or', 'or')} `);
|
||||||
|
}
|
||||||
|
if (!condition.field) return '';
|
||||||
|
const parts = [condition.field];
|
||||||
|
if (condition.equals !== undefined) parts.push(`= ${condition.equals}`);
|
||||||
|
if (condition.notEquals !== undefined) parts.push(`≠ ${condition.notEquals}`);
|
||||||
|
if (condition.in !== undefined) parts.push(`∈ [${condition.in.join(', ')}]`);
|
||||||
|
if (condition.isSet !== undefined) {
|
||||||
|
parts.push(condition.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'));
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field/operator/value trio for one condition — a plain condition, or one
|
||||||
|
* arm of an `anyOf`. No enable switch of its own; the caller owns whether
|
||||||
|
* this row exists at all.
|
||||||
|
*/
|
||||||
|
function ConditionArmFields({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
targets,
|
||||||
|
palette,
|
||||||
|
}: {
|
||||||
|
value: ConditionArm;
|
||||||
|
onChange: (value: ConditionArm) => void;
|
||||||
|
targets: ConditionTarget[];
|
||||||
|
palette: FormSchemaPalette | undefined;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localized = useLocalized();
|
||||||
|
|
||||||
|
const operator = operatorOf(value) ?? 'equals';
|
||||||
|
const target = targets.find((c) => c.path === value.field);
|
||||||
|
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
|
||||||
|
|
||||||
|
function setField(field: string) {
|
||||||
|
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOperator(next: Operator) {
|
||||||
|
if (!value.field) return;
|
||||||
|
const base: ConditionArm = { field: value.field };
|
||||||
|
if (next === 'isSet') base.isSet = true;
|
||||||
|
else if (next === 'in') base.in = [];
|
||||||
|
else if (next === 'notEquals') base.notEquals = '';
|
||||||
|
else base.equals = '';
|
||||||
|
onChange(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValueRaw(raw: string) {
|
||||||
|
if (!value.field) return;
|
||||||
|
const coerced = coerce(raw, target?.field.type);
|
||||||
|
if (operator === 'equals') onChange({ field: value.field, equals: coerced });
|
||||||
|
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInValues(raws: string[]) {
|
||||||
|
if (!value.field) return;
|
||||||
|
onChange({
|
||||||
|
field: value.field,
|
||||||
|
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Autocomplete
|
||||||
|
label={t('certReq.condition.field', 'Field path')}
|
||||||
|
placeholder="certificate.rank"
|
||||||
|
description={t(
|
||||||
|
'certReq.condition.fieldHelp',
|
||||||
|
'Dot path into the form, e.g. sectionKey.fieldKey',
|
||||||
|
)}
|
||||||
|
data={targets.map((c) => c.path)}
|
||||||
|
value={value.field ?? ''}
|
||||||
|
onChange={setField}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<Select
|
||||||
|
label={t('certReq.condition.operator', 'Operator')}
|
||||||
|
data={operators.map((op) => ({ value: op, label: op }))}
|
||||||
|
value={operator}
|
||||||
|
onChange={(v) => v && setOperator(v as Operator)}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
|
||||||
|
<Select
|
||||||
|
label={t('certReq.condition.value', 'Value')}
|
||||||
|
data={(target.field.options ?? []).map((o) => ({
|
||||||
|
value: o.value,
|
||||||
|
label: localized(o.label) || o.value,
|
||||||
|
}))}
|
||||||
|
value={String(value.equals ?? value.notEquals ?? '')}
|
||||||
|
onChange={(v) => v !== null && setValueRaw(v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
|
||||||
|
target?.field.type === 'BOOLEAN' ? (
|
||||||
|
<Checkbox
|
||||||
|
mt="xl"
|
||||||
|
label={t('certReq.condition.value', 'Value')}
|
||||||
|
checked={Boolean(value.equals ?? value.notEquals ?? false)}
|
||||||
|
onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
label={t('certReq.condition.value', 'Value')}
|
||||||
|
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'}
|
||||||
|
value={String(value.equals ?? value.notEquals ?? '')}
|
||||||
|
onChange={(e) => setValueRaw(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{operator === 'in' && target?.field.type === 'SELECT' && (
|
||||||
|
<Select
|
||||||
|
label={t('certReq.condition.values', 'Any of')}
|
||||||
|
data={(target.field.options ?? []).map((o) => ({
|
||||||
|
value: o.value,
|
||||||
|
label: localized(o.label) || o.value,
|
||||||
|
}))}
|
||||||
|
multiple={undefined}
|
||||||
|
value={null}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (!v) return;
|
||||||
|
const current = (value.in ?? []) as string[];
|
||||||
|
if (!current.includes(v)) setInValues([...current, v]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{operator === 'in' && target?.field.type !== 'SELECT' && (
|
||||||
|
<TextInput
|
||||||
|
label={t('certReq.condition.values', 'Any of (comma-separated)')}
|
||||||
|
value={(value.in ?? []).join(', ')}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInValues(
|
||||||
|
e.currentTarget.value
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{operator === 'in' && (value.in?.length ?? 0) > 0 && (
|
||||||
|
<Group gap={4}>
|
||||||
|
{(value.in ?? []).map((v, i) => (
|
||||||
|
<Text
|
||||||
|
key={`${v}-${i}`}
|
||||||
|
fz="xs"
|
||||||
|
px={6}
|
||||||
|
py={2}
|
||||||
|
bg="var(--mantine-color-gray-1)"
|
||||||
|
style={{ borderRadius: 4, cursor: 'pointer' }}
|
||||||
|
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
||||||
|
title={t('certReq.condition.removeValue', 'Click to remove')}
|
||||||
|
>
|
||||||
|
{String(v)} ×
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!target && value.field && (
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{t(
|
||||||
|
'certReq.condition.unknownField',
|
||||||
|
'This path is not a field in the current schema yet — it will still be saved as typed.',
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_ARM: ConditionArm = { field: '', equals: '' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authors one `FieldCondition` (`showWhen` on a section/field, or
|
* Authors one `FieldCondition` (`showWhen` on a section/field, or
|
||||||
* `conditionExpression` on a document requirement).
|
* `conditionExpression` on a document requirement).
|
||||||
@@ -38,6 +232,11 @@ function coerce(raw: string, targetType: string | undefined): string | number |
|
|||||||
* SELECT field, the value picker switches to that field's own options
|
* SELECT field, the value picker switches to that field's own options
|
||||||
* instead of free text — the condition can only ever reference an answer
|
* instead of free text — the condition can only ever reference an answer
|
||||||
* that could actually be chosen.
|
* that could actually be chosen.
|
||||||
|
*
|
||||||
|
* "Any of these" switches to authoring several single-field conditions whose
|
||||||
|
* OR is the real condition — needed when the same logical value can live on
|
||||||
|
* one of several mutually-exclusive fields (e.g. a rank split by
|
||||||
|
* department, see FieldCondition.anyOf).
|
||||||
*/
|
*/
|
||||||
export function ConditionBuilder({
|
export function ConditionBuilder({
|
||||||
value,
|
value,
|
||||||
@@ -54,40 +253,35 @@ export function ConditionBuilder({
|
|||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const localized = useLocalized();
|
|
||||||
|
|
||||||
const active = value !== null;
|
const active = value !== null;
|
||||||
const operator = operatorOf(value ?? undefined) ?? 'equals';
|
const isAnyOf = Boolean(value?.anyOf);
|
||||||
const target = targets.find((c) => c.path === value?.field);
|
const arms = (value?.anyOf ?? []) as ConditionArm[];
|
||||||
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
|
|
||||||
|
|
||||||
function setField(field: string) {
|
function setArm(i: number, arm: ConditionArm) {
|
||||||
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
|
const next = arms.slice();
|
||||||
|
next[i] = arm;
|
||||||
|
onChange({ anyOf: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOperator(next: Operator) {
|
function addArm() {
|
||||||
if (!value?.field) return;
|
onChange({ anyOf: [...arms, { ...EMPTY_ARM }] });
|
||||||
const base: ConditionValue = { field: value.field };
|
|
||||||
if (next === 'isSet') base.isSet = true;
|
|
||||||
else if (next === 'in') base.in = [];
|
|
||||||
else if (next === 'notEquals') base.notEquals = '';
|
|
||||||
else base.equals = '';
|
|
||||||
onChange(base);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setValueRaw(raw: string) {
|
function removeArm(i: number) {
|
||||||
if (!value?.field) return;
|
onChange({ anyOf: arms.filter((_, idx) => idx !== i) });
|
||||||
const coerced = coerce(raw, target?.field.type);
|
|
||||||
if (operator === 'equals') onChange({ field: value.field, equals: coerced });
|
|
||||||
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInValues(raws: string[]) {
|
function toggleAnyOf(next: boolean) {
|
||||||
if (!value?.field) return;
|
if (next) {
|
||||||
onChange({
|
// Seed the list from whatever single condition already existed, so
|
||||||
field: value.field,
|
// switching modes doesn't discard work in progress.
|
||||||
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
|
const seed: ConditionArm = value?.field ? (value as ConditionArm) : { ...EMPTY_ARM };
|
||||||
});
|
onChange({ anyOf: [seed] });
|
||||||
|
} else {
|
||||||
|
// Same, in reverse — the first arm becomes the single condition.
|
||||||
|
onChange((arms[0] as ConditionValue) ?? { field: '', equals: '' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,116 +296,58 @@ export function ConditionBuilder({
|
|||||||
|
|
||||||
{active && (
|
{active && (
|
||||||
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
|
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
|
||||||
<Autocomplete
|
<Switch
|
||||||
label={t('certReq.condition.field', 'Field path')}
|
size="sm"
|
||||||
placeholder="certificate.rank"
|
label={t(
|
||||||
description={t(
|
'certReq.condition.anyOfEnable',
|
||||||
'certReq.condition.fieldHelp',
|
'Any of these (the value can live on one of several fields)',
|
||||||
'Dot path into the form, e.g. sectionKey.fieldKey',
|
|
||||||
)}
|
)}
|
||||||
data={targets.map((c) => c.path)}
|
checked={isAnyOf}
|
||||||
value={value?.field ?? ''}
|
onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
|
||||||
onChange={setField}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
{isAnyOf ? (
|
||||||
<Select
|
<Stack gap="sm">
|
||||||
label={t('certReq.condition.operator', 'Operator')}
|
{arms.map((arm, i) => (
|
||||||
data={operators.map((op) => ({ value: op, label: op }))}
|
<Paper key={i} withBorder p="sm" radius="sm">
|
||||||
value={operator}
|
<Group justify="space-between" mb="xs">
|
||||||
onChange={(v) => v && setOperator(v as Operator)}
|
<Text fz="xs" fw={600} c="dimmed">
|
||||||
allowDeselect={false}
|
{t('certReq.condition.anyOfArm', 'Condition {{n}}', { n: i + 1 })}
|
||||||
/>
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
|
variant="subtle"
|
||||||
<Select
|
color="red"
|
||||||
label={t('certReq.condition.value', 'Value')}
|
size="sm"
|
||||||
data={(target.field.options ?? []).map((o) => ({
|
disabled={arms.length <= 1}
|
||||||
value: o.value,
|
onClick={() => removeArm(i)}
|
||||||
label: localized(o.label) || o.value,
|
>
|
||||||
}))}
|
<IconTrash size={14} />
|
||||||
value={String(value?.equals ?? value?.notEquals ?? '')}
|
</ActionIcon>
|
||||||
onChange={(v) => v !== null && setValueRaw(v)}
|
</Group>
|
||||||
/>
|
<ConditionArmFields
|
||||||
)}
|
value={arm}
|
||||||
|
onChange={(next) => setArm(i, next)}
|
||||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
|
targets={targets}
|
||||||
target?.field.type === 'BOOLEAN' ? (
|
palette={palette}
|
||||||
<Checkbox
|
/>
|
||||||
mt="xl"
|
</Paper>
|
||||||
label={t('certReq.condition.value', 'Value')}
|
|
||||||
checked={Boolean(value?.equals ?? value?.notEquals ?? false)}
|
|
||||||
onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextInput
|
|
||||||
label={t('certReq.condition.value', 'Value')}
|
|
||||||
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'}
|
|
||||||
value={String(value?.equals ?? value?.notEquals ?? '')}
|
|
||||||
onChange={(e) => setValueRaw(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{operator === 'in' && target?.field.type === 'SELECT' && (
|
|
||||||
<Select
|
|
||||||
label={t('certReq.condition.values', 'Any of')}
|
|
||||||
data={(target.field.options ?? []).map((o) => ({
|
|
||||||
value: o.value,
|
|
||||||
label: localized(o.label) || o.value,
|
|
||||||
}))}
|
|
||||||
multiple={undefined}
|
|
||||||
value={null}
|
|
||||||
onChange={(v) => {
|
|
||||||
if (!v) return;
|
|
||||||
const current = (value?.in ?? []) as string[];
|
|
||||||
if (!current.includes(v)) setInValues([...current, v]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{operator === 'in' && target?.field.type !== 'SELECT' && (
|
|
||||||
<TextInput
|
|
||||||
label={t('certReq.condition.values', 'Any of (comma-separated)')}
|
|
||||||
value={(value?.in ?? []).join(', ')}
|
|
||||||
onChange={(e) =>
|
|
||||||
setInValues(
|
|
||||||
e.currentTarget.value
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{operator === 'in' && (value?.in?.length ?? 0) > 0 && (
|
|
||||||
<Group gap={4}>
|
|
||||||
{(value?.in ?? []).map((v, i) => (
|
|
||||||
<Text
|
|
||||||
key={`${v}-${i}`}
|
|
||||||
fz="xs"
|
|
||||||
px={6}
|
|
||||||
py={2}
|
|
||||||
bg="var(--mantine-color-gray-1)"
|
|
||||||
style={{ borderRadius: 4, cursor: 'pointer' }}
|
|
||||||
onClick={() => setInValues((value?.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
|
||||||
title={t('certReq.condition.removeValue', 'Click to remove')}
|
|
||||||
>
|
|
||||||
{String(v)} ×
|
|
||||||
</Text>
|
|
||||||
))}
|
))}
|
||||||
</Group>
|
<Group>
|
||||||
)}
|
<ActionIcon variant="light" onClick={addArm}>
|
||||||
|
<IconPlus size={16} />
|
||||||
{!target && value?.field && (
|
</ActionIcon>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{t(
|
{t('certReq.condition.anyOfAdd', 'Add another field')}
|
||||||
'certReq.condition.unknownField',
|
</Text>
|
||||||
'This path is not a field in the current schema yet — it will still be saved as typed.',
|
</Group>
|
||||||
)}
|
</Stack>
|
||||||
</Text>
|
) : (
|
||||||
|
<ConditionArmFields
|
||||||
|
value={value as ConditionArm}
|
||||||
|
onChange={(next) => onChange(next)}
|
||||||
|
targets={targets}
|
||||||
|
palette={palette}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!draft.name.en?.trim()) return;
|
if (!draft.name.en?.trim()) return;
|
||||||
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) {
|
const hasCondition =
|
||||||
|
Boolean(draft.conditionExpression?.field) ||
|
||||||
|
Boolean(draft.conditionExpression?.anyOf?.length);
|
||||||
|
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
|
||||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { collectConditionTargets } from '../config/schema-paths';
|
import { collectConditionTargets } from '../config/schema-paths';
|
||||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||||
|
import { describeCondition } from './ConditionBuilder';
|
||||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||||
|
|
||||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
||||||
@@ -137,13 +138,9 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
|||||||
<Text fz="xs" c="dimmed" truncate>
|
<Text fz="xs" c="dimmed" truncate>
|
||||||
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
|
{req.mode === 'CONDITIONAL' && req.conditionExpression && (
|
||||||
<Text fz="xs" c="violet">
|
<Text fz="xs" c="violet">
|
||||||
{t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '}
|
{t('certReq.doc.when', 'when')} {describeCondition(req.conditionExpression, t)}
|
||||||
{req.conditionExpression.equals !== undefined && `= ${req.conditionExpression.equals}`}
|
|
||||||
{req.conditionExpression.notEquals !== undefined && `≠ ${req.conditionExpression.notEquals}`}
|
|
||||||
{req.conditionExpression.in !== undefined && `∈ [${req.conditionExpression.in.join(', ')}]`}
|
|
||||||
{req.conditionExpression.isSet !== undefined && (req.conditionExpression.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'))}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,23 +4,26 @@ import { useDisclosure } from '@mantine/hooks';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {IconPlus} from '@tabler/icons-react';
|
import {IconPlus} from '@tabler/icons-react';
|
||||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||||
|
import { useGetRanksQuery, useLocalized } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
useGetCertificationsQuery,
|
useGetCertificationsQuery,
|
||||||
useCreateCertificationMutation,
|
useCreateCertificationMutation,
|
||||||
useUpdateCertificationMutation,
|
useUpdateCertificationMutation,
|
||||||
useDeleteCertificationMutation,
|
useDeleteCertificationMutation,
|
||||||
} from '../../api/certification-api';
|
} from '../../api/certification-api';
|
||||||
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
|
import { type Certification } from '../../types/certification';
|
||||||
import { certificationColumns } from './columns';
|
import { certificationColumns } from './columns';
|
||||||
import { certificationActionsColumn } from './actions';
|
import { certificationActionsColumn } from './actions';
|
||||||
|
|
||||||
function CertificationForm({
|
function CertificationForm({
|
||||||
editing,
|
editing,
|
||||||
|
rankOptions,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
editing: Certification | null;
|
editing: Certification | null;
|
||||||
|
rankOptions: { value: string; label: string }[];
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -53,7 +56,7 @@ function CertificationForm({
|
|||||||
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
|
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
|
||||||
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
|
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
|
||||||
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
||||||
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
|
data={rankOptions}
|
||||||
value={rankKey}
|
value={rankKey}
|
||||||
onChange={setRankKey}
|
onChange={setRankKey}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -74,7 +77,10 @@ export function CertificationPage() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
const { handleError } = useErrorHandler();
|
const { handleError } = useErrorHandler();
|
||||||
|
const localized = useLocalized();
|
||||||
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||||
|
const { data: rankRes } = useGetRanksQuery();
|
||||||
|
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||||
@@ -154,6 +160,7 @@ export function CertificationPage() {
|
|||||||
{showForm && (
|
{showForm && (
|
||||||
<CertificationForm
|
<CertificationForm
|
||||||
editing={editing}
|
editing={editing}
|
||||||
|
rankOptions={rankOptions}
|
||||||
isSubmitting={isCreating || isUpdating}
|
isSubmitting={isCreating || isUpdating}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={resetForm}
|
onCancel={resetForm}
|
||||||
|
|||||||
@@ -3,24 +3,6 @@ export interface LocalePair {
|
|||||||
am: string;
|
am: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* STCW rank an exam certification is for — the join that lets the
|
|
||||||
* schedule-exam picker offer only sittings valid for an application's rank.
|
|
||||||
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
|
|
||||||
* `proficiency` on the backend. Not every certification is on the examined
|
|
||||||
* ladder, so this stays a plain optional string rather than a required enum.
|
|
||||||
*/
|
|
||||||
export const RANK_KEY_OPTIONS = [
|
|
||||||
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
|
|
||||||
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
|
|
||||||
{ value: 'MASTER', label: 'Master' },
|
|
||||||
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
|
|
||||||
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
|
|
||||||
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
|
|
||||||
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
|
|
||||||
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export interface Certification {
|
export interface Certification {
|
||||||
id: string;
|
id: string;
|
||||||
name: LocalePair;
|
name: LocalePair;
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconInfoCircle, IconPlus } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
AdvancedTable,
|
||||||
|
ModalFooter,
|
||||||
|
notify,
|
||||||
|
PageLoader,
|
||||||
|
useErrorHandler,
|
||||||
|
type AdvancedColumn,
|
||||||
|
} from "@ema-platform/ui";
|
||||||
|
import {
|
||||||
|
useLocalized,
|
||||||
|
useGetDepartmentsQuery,
|
||||||
|
useCreateDepartmentMutation,
|
||||||
|
useUpdateDepartmentMutation,
|
||||||
|
useDeleteDepartmentMutation,
|
||||||
|
useGetRanksQuery,
|
||||||
|
useCreateRankMutation,
|
||||||
|
useUpdateRankMutation,
|
||||||
|
useDeleteRankMutation,
|
||||||
|
type Department,
|
||||||
|
type Rank,
|
||||||
|
type RankCertificateCategory,
|
||||||
|
} from "@ema-platform/api";
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS: { value: RankCertificateCategory; label: string }[] = [
|
||||||
|
{ value: "COC", label: "CoC" },
|
||||||
|
{ value: "COP", label: "CoP" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Departments and their CoC/CoP rank ladders, as backoffice-editable config.
|
||||||
|
*
|
||||||
|
* Both used to be hardcoded (`ESeafarerDepartment` and the `COC_LADDERS`/
|
||||||
|
* `COP_LADDERS` arrays) — this is the write side that config never had. A
|
||||||
|
* rank's `ladderOrder` is the rung position `resolveNextRank` climbs, so
|
||||||
|
* reordering here changes what an applicant is auto-advanced to next.
|
||||||
|
*/
|
||||||
|
export function RankDepartmentTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localized = useLocalized();
|
||||||
|
|
||||||
|
const { data: deptRes, isLoading: deptLoading, isFetching: deptFetching, refetch: refetchDepts } =
|
||||||
|
useGetDepartmentsQuery();
|
||||||
|
const { data: rankRes, isLoading: rankLoading, isFetching: rankFetching, refetch: refetchRanks } =
|
||||||
|
useGetRanksQuery();
|
||||||
|
|
||||||
|
const departments = deptRes?.items ?? [];
|
||||||
|
const ranks = rankRes?.items ?? [];
|
||||||
|
|
||||||
|
const deptOptions = departments.map((d) => ({ value: d.id, label: localized(d.name) }));
|
||||||
|
const deptName = useCallback(
|
||||||
|
(id: string) => departments.find((d) => d.id === id)?.code ?? "-",
|
||||||
|
[departments],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deptLoading || rankLoading) {
|
||||||
|
return <PageLoader label={t("configuration.loadingRanks", "Loading departments and ranks…")} height={300} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"configuration.rankLadderNotice",
|
||||||
|
"A rank's position is its rung on the ladder — an applicant is auto-advanced to the next position up from what they already hold.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DepartmentSection
|
||||||
|
departments={departments}
|
||||||
|
isFetching={deptFetching}
|
||||||
|
refetch={refetchDepts}
|
||||||
|
localized={localized}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RankSection
|
||||||
|
ranks={ranks}
|
||||||
|
deptOptions={deptOptions}
|
||||||
|
deptName={deptName}
|
||||||
|
isFetching={rankFetching}
|
||||||
|
refetch={refetchRanks}
|
||||||
|
localized={localized}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- departments
|
||||||
|
|
||||||
|
function DepartmentSection({
|
||||||
|
departments,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
localized,
|
||||||
|
}: {
|
||||||
|
departments: Department[];
|
||||||
|
isFetching: boolean;
|
||||||
|
refetch: () => void;
|
||||||
|
localized: (v: Department["name"]) => string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
|
const [createDepartment, { isLoading: isCreating }] = useCreateDepartmentMutation();
|
||||||
|
const [updateDepartment, { isLoading: isUpdating }] = useUpdateDepartmentMutation();
|
||||||
|
const [deleteDepartment] = useDeleteDepartmentMutation();
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<Department | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Department | null>(null);
|
||||||
|
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setEditing(null);
|
||||||
|
setShowForm(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: { code: "", nameEn: "", nameAm: "", sortOrder: 0 },
|
||||||
|
validate: {
|
||||||
|
code: (v) => (!v ? t("configuration.validation.codeRequired", "Code is required") : null),
|
||||||
|
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
|
||||||
|
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEdit = useCallback(
|
||||||
|
(dept: Department) => {
|
||||||
|
setEditing(dept);
|
||||||
|
form.setValues({
|
||||||
|
code: dept.code,
|
||||||
|
nameEn: dept.name.en ?? "",
|
||||||
|
nameAm: dept.name.am ?? "",
|
||||||
|
sortOrder: dept.sortOrder,
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
|
const name = { en: values.nameEn, am: values.nameAm };
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateDepartment({
|
||||||
|
id: editing.id,
|
||||||
|
code: values.code,
|
||||||
|
name,
|
||||||
|
sortOrder: values.sortOrder,
|
||||||
|
}).unwrap();
|
||||||
|
notify.success(t("configuration.updated"));
|
||||||
|
} else {
|
||||||
|
await createDepartment({ code: values.code, name, sortOrder: values.sortOrder }).unwrap();
|
||||||
|
notify.success(t("configuration.created"));
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
form.reset();
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteDepartment(deleteTarget.id).unwrap();
|
||||||
|
notify.success(t("configuration.deleted"));
|
||||||
|
closeDelete();
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
}, [deleteTarget, deleteDepartment, closeDelete, handleError]);
|
||||||
|
|
||||||
|
const columns: AdvancedColumn<Department>[] = [
|
||||||
|
{ header: t("configuration.code", "Code"), cell: ({ row }) => row.original.code },
|
||||||
|
{ header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) },
|
||||||
|
{ header: t("configuration.sortOrder", "Order"), cell: ({ row }) => row.original.sortOrder },
|
||||||
|
{
|
||||||
|
header: "actions",
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)}>
|
||||||
|
{t("configuration.edit", "Edit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget(row.original);
|
||||||
|
openDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("configuration.delete")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<Title order={4}>{t("configuration.departments", "Departments")}</Title>
|
||||||
|
{!showForm && (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
form.reset();
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("configuration.addDepartment", "Add department")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<AdvancedTable
|
||||||
|
columns={columns}
|
||||||
|
data={[...departments].sort((a, b) => a.sortOrder - b.sortOrder)}
|
||||||
|
tableName={t("configuration.departments", "Departments")}
|
||||||
|
itemCount={departments.length}
|
||||||
|
pageIndex={0}
|
||||||
|
onPageChange={() => {}}
|
||||||
|
pageSize={departments.length || 10}
|
||||||
|
onPageSizeChange={() => {}}
|
||||||
|
refresh={refetch}
|
||||||
|
isLoading={isFetching}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={showForm}
|
||||||
|
onClose={resetForm}
|
||||||
|
title={editing ? t("configuration.update") : t("configuration.addDepartment", "Add department")}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
label={t("configuration.code", "Code")}
|
||||||
|
placeholder="DECK"
|
||||||
|
{...form.getInputProps("code")}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<TextInput label={t("configuration.nameEn")} {...form.getInputProps("nameEn")} size="sm" />
|
||||||
|
<TextInput label={t("configuration.nameAm")} {...form.getInputProps("nameAm")} size="sm" />
|
||||||
|
<NumberInput
|
||||||
|
label={t("configuration.sortOrder", "Sort order")}
|
||||||
|
{...form.getInputProps("sortOrder")}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="default" onClick={resetForm} size="sm">
|
||||||
|
{t("configuration.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
|
||||||
|
{editing ? t("configuration.update") : t("configuration.create")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t("configuration.confirmDelete")} size="sm">
|
||||||
|
<Text mb="md">
|
||||||
|
{t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
|
||||||
|
</Text>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="default" onClick={closeDelete} size="sm">
|
||||||
|
{t("configuration.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={confirmDelete} size="sm">
|
||||||
|
{t("configuration.delete")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ranks
|
||||||
|
|
||||||
|
function RankSection({
|
||||||
|
ranks,
|
||||||
|
deptOptions,
|
||||||
|
deptName,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
localized,
|
||||||
|
}: {
|
||||||
|
ranks: Rank[];
|
||||||
|
deptOptions: { value: string; label: string }[];
|
||||||
|
deptName: (id: string) => string;
|
||||||
|
isFetching: boolean;
|
||||||
|
refetch: () => void;
|
||||||
|
localized: (v: Rank["name"]) => string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
|
const [createRank, { isLoading: isCreating }] = useCreateRankMutation();
|
||||||
|
const [updateRank, { isLoading: isUpdating }] = useUpdateRankMutation();
|
||||||
|
const [deleteRank] = useDeleteRankMutation();
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<Rank | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Rank | null>(null);
|
||||||
|
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setEditing(null);
|
||||||
|
setShowForm(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
departmentId: "",
|
||||||
|
certificateCategory: "COC" as RankCertificateCategory,
|
||||||
|
key: "",
|
||||||
|
nameEn: "",
|
||||||
|
nameAm: "",
|
||||||
|
ladderOrder: 0,
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
departmentId: (v) => (!v ? t("configuration.validation.departmentRequired") : null),
|
||||||
|
key: (v) => (!v ? t("configuration.validation.keyRequired", "Key is required") : null),
|
||||||
|
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
|
||||||
|
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEdit = useCallback(
|
||||||
|
(rank: Rank) => {
|
||||||
|
setEditing(rank);
|
||||||
|
form.setValues({
|
||||||
|
departmentId: rank.departmentId,
|
||||||
|
certificateCategory: rank.certificateCategory,
|
||||||
|
key: rank.key,
|
||||||
|
nameEn: rank.name.en ?? "",
|
||||||
|
nameAm: rank.name.am ?? "",
|
||||||
|
ladderOrder: rank.ladderOrder,
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
|
const name = { en: values.nameEn, am: values.nameAm };
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateRank({
|
||||||
|
id: editing.id,
|
||||||
|
departmentId: values.departmentId,
|
||||||
|
certificateCategory: values.certificateCategory,
|
||||||
|
key: values.key,
|
||||||
|
name,
|
||||||
|
ladderOrder: values.ladderOrder,
|
||||||
|
}).unwrap();
|
||||||
|
notify.success(t("configuration.updated"));
|
||||||
|
} else {
|
||||||
|
await createRank({
|
||||||
|
departmentId: values.departmentId,
|
||||||
|
certificateCategory: values.certificateCategory,
|
||||||
|
key: values.key,
|
||||||
|
name,
|
||||||
|
ladderOrder: values.ladderOrder,
|
||||||
|
}).unwrap();
|
||||||
|
notify.success(t("configuration.created"));
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
form.reset();
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteRank(deleteTarget.id).unwrap();
|
||||||
|
notify.success(t("configuration.deleted"));
|
||||||
|
closeDelete();
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
}, [deleteTarget, deleteRank, closeDelete, handleError]);
|
||||||
|
|
||||||
|
const sortedRanks = [...ranks].sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.departmentId.localeCompare(b.departmentId) ||
|
||||||
|
a.certificateCategory.localeCompare(b.certificateCategory) ||
|
||||||
|
a.ladderOrder - b.ladderOrder,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: AdvancedColumn<Rank>[] = [
|
||||||
|
{ header: t("configuration.department"), cell: ({ row }) => deptName(row.original.departmentId) },
|
||||||
|
{ header: t("configuration.category", "Ladder"), cell: ({ row }) => row.original.certificateCategory },
|
||||||
|
{ header: t("configuration.rankOrder", "Rung"), cell: ({ row }) => row.original.ladderOrder },
|
||||||
|
{ header: t("configuration.key", "Key"), cell: ({ row }) => row.original.key },
|
||||||
|
{ header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) },
|
||||||
|
{
|
||||||
|
header: "actions",
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)}>
|
||||||
|
{t("configuration.edit", "Edit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget(row.original);
|
||||||
|
openDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("configuration.delete")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<Title order={4}>{t("configuration.ranks", "Ranks")}</Title>
|
||||||
|
{!showForm && (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
form.reset();
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
disabled={deptOptions.length === 0}
|
||||||
|
>
|
||||||
|
{t("configuration.addRank", "Add rank")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<AdvancedTable
|
||||||
|
columns={columns}
|
||||||
|
data={sortedRanks}
|
||||||
|
tableName={t("configuration.ranks", "Ranks")}
|
||||||
|
itemCount={sortedRanks.length}
|
||||||
|
pageIndex={0}
|
||||||
|
onPageChange={() => {}}
|
||||||
|
pageSize={sortedRanks.length || 10}
|
||||||
|
onPageSizeChange={() => {}}
|
||||||
|
refresh={refetch}
|
||||||
|
isLoading={isFetching}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={showForm}
|
||||||
|
onClose={resetForm}
|
||||||
|
title={editing ? t("configuration.update") : t("configuration.addRank", "Add rank")}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Select
|
||||||
|
label={t("configuration.department")}
|
||||||
|
data={deptOptions}
|
||||||
|
{...form.getInputProps("departmentId")}
|
||||||
|
size="sm"
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t("configuration.category", "Ladder")}
|
||||||
|
data={CATEGORY_OPTIONS}
|
||||||
|
{...form.getInputProps("certificateCategory")}
|
||||||
|
size="sm"
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t("configuration.key", "Key")}
|
||||||
|
placeholder="CHIEF_MATE"
|
||||||
|
{...form.getInputProps("key")}
|
||||||
|
size="sm"
|
||||||
|
disabled={!!editing}
|
||||||
|
description={
|
||||||
|
editing
|
||||||
|
? t(
|
||||||
|
"configuration.keyLockedNotice",
|
||||||
|
"Not editable — issued licences already carry this key.",
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextInput label={t("configuration.nameEn")} {...form.getInputProps("nameEn")} size="sm" />
|
||||||
|
<TextInput label={t("configuration.nameAm")} {...form.getInputProps("nameAm")} size="sm" />
|
||||||
|
<NumberInput
|
||||||
|
label={t("configuration.rankOrder", "Rung (0 = entry rank)")}
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps("ladderOrder")}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="default" onClick={resetForm} size="sm">
|
||||||
|
{t("configuration.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
|
||||||
|
{editing ? t("configuration.update") : t("configuration.create")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t("configuration.confirmDelete")} size="sm">
|
||||||
|
<Text mb="md">
|
||||||
|
{t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
|
||||||
|
</Text>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="default" onClick={closeDelete} size="sm">
|
||||||
|
{t("configuration.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={confirmDelete} size="sm">
|
||||||
|
{t("configuration.delete")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconHash,
|
IconHash,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconAnchor,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
import { LocationPage } from "../../../location/pages/LocationPage";
|
import { LocationPage } from "../../../location/pages/LocationPage";
|
||||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||||
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
||||||
|
import { RankDepartmentTab } from "./RankDepartmentTab";
|
||||||
import {
|
import {
|
||||||
useGetOrganizationsQuery,
|
useGetOrganizationsQuery,
|
||||||
useGetProfessionsQuery,
|
useGetProfessionsQuery,
|
||||||
@@ -401,6 +403,9 @@ export function ConfigurationPage() {
|
|||||||
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
|
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
|
||||||
{t("numberFormat.title", "Number Formats")}
|
{t("numberFormat.title", "Number Formats")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
|
||||||
|
{t("configuration.ranksTab", "Ranks & Departments")}
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="professions" pt="md">
|
<Tabs.Panel value="professions" pt="md">
|
||||||
@@ -418,6 +423,10 @@ export function ConfigurationPage() {
|
|||||||
<Tabs.Panel value="numberFormats" pt="md">
|
<Tabs.Panel value="numberFormats" pt="md">
|
||||||
<NumberFormatTab />
|
<NumberFormatTab />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="ranks" pt="md">
|
||||||
|
<RankDepartmentTab />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ const STATUS_TONE: Record<string, StatusTone> = {
|
|||||||
PUBLISHED: 'success',
|
PUBLISHED: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
|
const FORM_LABEL: Record<string, string> = {
|
||||||
|
ESSAY: "Essay",
|
||||||
|
CHOICE: "Choice",
|
||||||
|
BOTH: "Both",
|
||||||
|
};
|
||||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
|
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
|
||||||
const ADMIN_LABEL: Record<string, string> = {
|
const ADMIN_LABEL: Record<string, string> = {
|
||||||
OFFLINE: "Offline",
|
OFFLINE: "Offline",
|
||||||
@@ -117,13 +121,18 @@ export function ExamDetailPage() {
|
|||||||
|
|
||||||
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
|
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
|
||||||
// must not offer drafts or retired questions either.
|
// must not offer drafts or retired questions either.
|
||||||
|
//
|
||||||
|
// BOTH describes a mixed paper — a question itself is never "BOTH" (see
|
||||||
|
// QuestionForm), so an equality check against it would match nothing and
|
||||||
|
// silently offer zero questions. Same skip-condition as the backend's own
|
||||||
|
// random draw (ExamService.selectRandomQuestions).
|
||||||
const eligibleQuestions = useMemo(() => {
|
const eligibleQuestions = useMemo(() => {
|
||||||
if (!exam) return [];
|
if (!exam) return [];
|
||||||
return allQuestions
|
return allQuestions
|
||||||
.filter(
|
.filter(
|
||||||
(q) =>
|
(q) =>
|
||||||
q.certificationId === exam.certificationId &&
|
q.certificationId === exam.certificationId &&
|
||||||
q.form === exam.form &&
|
(exam.form === 'BOTH' || q.form === exam.form) &&
|
||||||
q.status === 'APPROVED',
|
q.status === 'APPROVED',
|
||||||
)
|
)
|
||||||
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
|
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||||
|
import { useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
||||||
import {
|
import {
|
||||||
useGetExamsQuery,
|
useGetExamsQuery,
|
||||||
useCreateExamMutation,
|
useCreateExamMutation,
|
||||||
@@ -248,6 +249,7 @@ function ExamForm({
|
|||||||
data={[
|
data={[
|
||||||
{ value: "ESSAY", label: t("exam.form.essay") },
|
{ value: "ESSAY", label: t("exam.form.essay") },
|
||||||
{ value: "CHOICE", label: t("exam.form.choice") },
|
{ value: "CHOICE", label: t("exam.form.choice") },
|
||||||
|
{ value: "BOTH", label: t("exam.form.both") },
|
||||||
]}
|
]}
|
||||||
value={form}
|
value={form}
|
||||||
onChange={setForm}
|
onChange={setForm}
|
||||||
@@ -362,7 +364,10 @@ export function ExamPage() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { handleError } = useErrorHandler();
|
const { handleError } = useErrorHandler();
|
||||||
const locale = i18n.language as "en" | "am";
|
const locale = i18n.language as "en" | "am";
|
||||||
|
const localized = useLocalized();
|
||||||
const { data: certRes } = useGetCertificationsQuery();
|
const { data: certRes } = useGetCertificationsQuery();
|
||||||
|
const { data: rankRes } = useGetRanksQuery();
|
||||||
|
const rankLabelByKey = new Map((rankRes?.items ?? []).map((r) => [r.key, localized(r.name)]));
|
||||||
const { data, isFetching, isError, refetch } = useGetExamsQuery();
|
const { data, isFetching, isError, refetch } = useGetExamsQuery();
|
||||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||||
@@ -383,9 +388,18 @@ export function ExamPage() {
|
|||||||
const [statusOpened, { open: openStatus, close: closeStatus }] =
|
const [statusOpened, { open: openStatus, close: closeStatus }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
|
|
||||||
|
// Rank in the label: an exam inherits its STCW rank from the certification
|
||||||
|
// it is created under (Certification.rankKey), so the officer sees which
|
||||||
|
// rank a sitting will serve at the moment they pick the subject.
|
||||||
const certOptions = certifications
|
const certOptions = certifications
|
||||||
.filter((c) => c.isActive)
|
.filter((c) => c.isActive)
|
||||||
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
.map((c) => {
|
||||||
|
const rank = c.rankKey ? rankLabelByKey.get(c.rankKey) : undefined;
|
||||||
|
return {
|
||||||
|
value: c.id,
|
||||||
|
label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale],
|
||||||
|
};
|
||||||
|
});
|
||||||
const getCertName = (id: string) =>
|
const getCertName = (id: string) =>
|
||||||
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ import type { EstimatedTime } from "../../question/types/question";
|
|||||||
import type { QuestionForm } from "../../question/types/question";
|
import type { QuestionForm } from "../../question/types/question";
|
||||||
export type { QuestionForm };
|
export type { QuestionForm };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paper's own form — unlike a question's (QuestionForm), an exam may mix
|
||||||
|
* both: BOTH means a mixed paper, resolved per-question against each
|
||||||
|
* question's own ESSAY/CHOICE form. Mirrors backend EExamForm.
|
||||||
|
*/
|
||||||
|
export type ExamForm = QuestionForm | "BOTH";
|
||||||
|
|
||||||
export type ExamType = "WRITTEN" | "ORAL";
|
export type ExamType = "WRITTEN" | "ORAL";
|
||||||
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
||||||
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
||||||
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
|
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
|
||||||
export type ExamStatus =
|
export type ExamStatus =
|
||||||
| "PENDING"
|
"PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED" | "PUBLISHED";
|
||||||
| "ACTIVE"
|
|
||||||
| "COMPLETED"
|
|
||||||
| "CANCELLED"
|
|
||||||
| "POSTPONED"
|
|
||||||
| "PUBLISHED";
|
|
||||||
|
|
||||||
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
||||||
export interface QuestionOptionBrief {
|
export interface QuestionOptionBrief {
|
||||||
@@ -39,7 +41,7 @@ export interface Exam {
|
|||||||
date: string;
|
date: string;
|
||||||
givenTime: EstimatedTime | null;
|
givenTime: EstimatedTime | null;
|
||||||
type: ExamType;
|
type: ExamType;
|
||||||
form: QuestionForm;
|
form: ExamForm;
|
||||||
venue: string;
|
venue: string;
|
||||||
administrationMethod: ExamAdministrationMethod;
|
administrationMethod: ExamAdministrationMethod;
|
||||||
evaluationMethod: ExamEvaluationMethod;
|
evaluationMethod: ExamEvaluationMethod;
|
||||||
@@ -63,7 +65,7 @@ export interface CreateExamPayload {
|
|||||||
date: string;
|
date: string;
|
||||||
givenTime: EstimatedTime;
|
givenTime: EstimatedTime;
|
||||||
type: ExamType;
|
type: ExamType;
|
||||||
form: QuestionForm;
|
form: ExamForm;
|
||||||
venue: string;
|
venue: string;
|
||||||
administrationMethod: ExamAdministrationMethod;
|
administrationMethod: ExamAdministrationMethod;
|
||||||
evaluationMethod: ExamEvaluationMethod;
|
evaluationMethod: ExamEvaluationMethod;
|
||||||
@@ -79,7 +81,7 @@ export interface UpdateExamPayload {
|
|||||||
date?: string;
|
date?: string;
|
||||||
givenTime?: EstimatedTime;
|
givenTime?: EstimatedTime;
|
||||||
type?: ExamType;
|
type?: ExamType;
|
||||||
form?: QuestionForm;
|
form?: ExamForm;
|
||||||
venue?: string;
|
venue?: string;
|
||||||
administrationMethod?: ExamAdministrationMethod;
|
administrationMethod?: ExamAdministrationMethod;
|
||||||
evaluationMethod?: ExamEvaluationMethod;
|
evaluationMethod?: ExamEvaluationMethod;
|
||||||
@@ -101,19 +103,14 @@ export interface RandomQuestionsPayload {
|
|||||||
|
|
||||||
/** What the invigilator recorded on the day (US-EXAM-009). */
|
/** What the invigilator recorded on the day (US-EXAM-009). */
|
||||||
export type AttendanceStatus =
|
export type AttendanceStatus =
|
||||||
| 'REGISTERED'
|
"REGISTERED" | "PRESENT" | "ABSENT" | "LATE" | "WITHDRAWN" | "DISQUALIFIED";
|
||||||
| 'PRESENT'
|
|
||||||
| 'ABSENT'
|
|
||||||
| 'LATE'
|
|
||||||
| 'WITHDRAWN'
|
|
||||||
| 'DISQUALIFIED';
|
|
||||||
|
|
||||||
export interface ExamRegistration {
|
export interface ExamRegistration {
|
||||||
id: string;
|
id: string;
|
||||||
examId: string;
|
examId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
admissionNumber: string;
|
admissionNumber: string;
|
||||||
kind: 'NEW' | 'RETAKE';
|
kind: "NEW" | "RETAKE";
|
||||||
attemptNumber: number;
|
attemptNumber: number;
|
||||||
attendanceStatus: AttendanceStatus;
|
attendanceStatus: AttendanceStatus;
|
||||||
attendanceRemark: string | null;
|
attendanceRemark: string | null;
|
||||||
@@ -127,12 +124,14 @@ export interface ExamRegistration {
|
|||||||
seafarerNumber: string | null;
|
seafarerNumber: string | null;
|
||||||
};
|
};
|
||||||
/** The candidate's online sitting, when one has been started. */
|
/** The candidate's online sitting, when one has been started. */
|
||||||
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
attempt?: {
|
||||||
|
id: string;
|
||||||
|
status: "IN_PROGRESS" | "SUBMITTED" | "EXPIRED";
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RegradeOutcome =
|
export type RegradeOutcome =
|
||||||
| { graded: true; resultId: string }
|
{ graded: true; resultId: string } | { graded: false; reason: string };
|
||||||
| { graded: false; reason: string };
|
|
||||||
|
|
||||||
export interface RecordAttendancePayload {
|
export interface RecordAttendancePayload {
|
||||||
registrationId: string;
|
registrationId: string;
|
||||||
@@ -141,17 +140,10 @@ export interface RecordAttendancePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ExamIncidentType =
|
export type ExamIncidentType =
|
||||||
| 'MISCONDUCT'
|
"MISCONDUCT" | "TECHNICAL_FAILURE" | "MEDICAL" | "ADMINISTRATIVE" | "OTHER";
|
||||||
| 'TECHNICAL_FAILURE'
|
|
||||||
| 'MEDICAL'
|
|
||||||
| 'ADMINISTRATIVE'
|
|
||||||
| 'OTHER';
|
|
||||||
|
|
||||||
export type ExamIncidentStatus =
|
export type ExamIncidentStatus =
|
||||||
| 'OPEN'
|
"OPEN" | "UNDER_REVIEW" | "RESOLVED" | "DISMISSED";
|
||||||
| 'UNDER_REVIEW'
|
|
||||||
| 'RESOLVED'
|
|
||||||
| 'DISMISSED';
|
|
||||||
|
|
||||||
export interface ExamIncident {
|
export interface ExamIncident {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -176,6 +168,6 @@ export interface CreateIncidentPayload {
|
|||||||
|
|
||||||
export interface ResolveIncidentPayload {
|
export interface ResolveIncidentPayload {
|
||||||
incidentId: string;
|
incidentId: string;
|
||||||
outcome: 'RESOLVED' | 'DISMISSED';
|
outcome: "RESOLVED" | "DISMISSED";
|
||||||
resolution: string;
|
resolution: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core';
|
||||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ModalFooter } from '@ema-platform/ui';
|
import { ModalFooter } from '@ema-platform/ui';
|
||||||
@@ -11,11 +11,7 @@ interface Props {
|
|||||||
applicantName: string;
|
applicantName: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (payload: {
|
onConfirm: (payload: { examId: string; examDate?: string }) => void;
|
||||||
examId: string;
|
|
||||||
admissionNumber?: string;
|
|
||||||
examDate?: string;
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +34,6 @@ export function ScheduleExamModal({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
|
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
|
||||||
const [examId, setExamId] = useState<string | null>(null);
|
const [examId, setExamId] = useState<string | null>(null);
|
||||||
const [admissionNumber, setAdmissionNumber] = useState('');
|
|
||||||
|
|
||||||
const options = (exams ?? []).map((exam) => ({
|
const options = (exams ?? []).map((exam) => ({
|
||||||
value: exam.id,
|
value: exam.id,
|
||||||
@@ -52,7 +47,6 @@ export function ScheduleExamModal({
|
|||||||
if (!examId) return;
|
if (!examId) return;
|
||||||
onConfirm({
|
onConfirm({
|
||||||
examId,
|
examId,
|
||||||
admissionNumber: admissionNumber.trim() || undefined,
|
|
||||||
examDate: selected?.date ? String(selected.date) : undefined,
|
examDate: selected?.date ? String(selected.date) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -92,15 +86,12 @@ export function ScheduleExamModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextInput
|
<Text size="xs" c="dimmed">
|
||||||
label={t('review.scheduleExam.admissionNumber', 'Admission number')}
|
{t(
|
||||||
description={t(
|
|
||||||
'review.scheduleExam.admissionHint',
|
'review.scheduleExam.admissionHint',
|
||||||
'Leave blank to let the system issue one.',
|
'An admission number is issued automatically when the candidate is seated.',
|
||||||
)}
|
)}
|
||||||
value={admissionNumber}
|
</Text>
|
||||||
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onClose}>
|
<Button variant="default" onClick={onClose}>
|
||||||
|
|||||||
@@ -65,16 +65,9 @@ export interface ActionDefinition {
|
|||||||
*/
|
*/
|
||||||
export const ACTIONS: ActionDefinition[] = [
|
export const ACTIONS: ActionDefinition[] = [
|
||||||
// ------------------------------------------------------------- workflow
|
// ------------------------------------------------------------- workflow
|
||||||
{
|
// Claim is deliberately absent here: an officer claims from the queue
|
||||||
id: 'claim',
|
// (LicenseQueuePage), not from this detail page. That implementation is
|
||||||
tier: 'workflow',
|
// separate — see LicenseQueuePage/actions.tsx — and is unaffected by this.
|
||||||
labelKey: 'review.actions.claim',
|
|
||||||
// Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP)
|
|
||||||
// sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED.
|
|
||||||
from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
|
|
||||||
permissions: ['can:claim:license-application'],
|
|
||||||
emphasis: 'light',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'assign',
|
id: 'assign',
|
||||||
tier: 'workflow',
|
tier: 'workflow',
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
SEAFARER_REGISTRATION_STATUS_TONES,
|
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||||
displaySeafarerAnswer,
|
displaySeafarerAnswer,
|
||||||
|
useGetActiveDepartmentsQuery,
|
||||||
useListSeafarerRegistrationsQuery,
|
useListSeafarerRegistrationsQuery,
|
||||||
|
useLocalized,
|
||||||
type SeafarerRegistration,
|
type SeafarerRegistration,
|
||||||
type SeafarerRegistrationStatus,
|
type SeafarerRegistrationStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
@@ -28,12 +30,16 @@ export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middl
|
|||||||
export function SeafarerRegistrationQueuePage() {
|
export function SeafarerRegistrationQueuePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
|
const localized = useLocalized();
|
||||||
const [status, setStatus] = useState<SeafarerRegistrationStatus | null>(null);
|
const [status, setStatus] = useState<SeafarerRegistrationStatus | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||||
|
|
||||||
|
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||||
|
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||||
|
|
||||||
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
|
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
|
||||||
status: status ?? undefined,
|
status: status ?? undefined,
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
@@ -69,7 +75,11 @@ export function SeafarerRegistrationQueuePage() {
|
|||||||
{
|
{
|
||||||
header: 'Department',
|
header: 'Department',
|
||||||
accessorKey: 'department',
|
accessorKey: 'department',
|
||||||
cell: ({ row }) => <Text size="sm">{displaySeafarerAnswer('department', row.original.department)}</Text>,
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm">
|
||||||
|
{displaySeafarerAnswer('department', row.original.department, departmentOptions)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Submitted',
|
header: 'Submitted',
|
||||||
@@ -100,7 +110,7 @@ export function SeafarerRegistrationQueuePage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[showDate],
|
[showDate, departmentOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
displaySeafarerAnswer,
|
displaySeafarerAnswer,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
useApproveSeafarerRegistrationMutation,
|
useApproveSeafarerRegistrationMutation,
|
||||||
|
useGetActiveDepartmentsQuery,
|
||||||
useGetSeafarerRegistrationReviewQuery,
|
useGetSeafarerRegistrationReviewQuery,
|
||||||
|
useLocalized,
|
||||||
useRejectSeafarerRegistrationMutation,
|
useRejectSeafarerRegistrationMutation,
|
||||||
useRequestSeafarerRegistrationChangesMutation,
|
useRequestSeafarerRegistrationChangesMutation,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
@@ -31,7 +33,10 @@ const DECISION_COPY: Record<Decision, { title: string; label: string; color: str
|
|||||||
export function SeafarerRegistrationReviewPage() {
|
export function SeafarerRegistrationReviewPage() {
|
||||||
const { id = '' } = useParams();
|
const { id = '' } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const localized = useLocalized();
|
||||||
const { data, isLoading, error } = useGetSeafarerRegistrationReviewQuery(id, { skip: !id });
|
const { data, isLoading, error } = useGetSeafarerRegistrationReviewQuery(id, { skip: !id });
|
||||||
|
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||||
|
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||||
|
|
||||||
const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation();
|
const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation();
|
||||||
const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
|
const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
|
||||||
@@ -166,7 +171,9 @@ export function SeafarerRegistrationReviewPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">{displaySeafarerAnswer(field, registration[field])}</Text>
|
<Text size="sm">
|
||||||
|
{displaySeafarerAnswer(field, registration[field], departmentOptions)}
|
||||||
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export const am: Translations = {
|
|||||||
oral: "ቃል",
|
oral: "ቃል",
|
||||||
essay: "ኢሴይ",
|
essay: "ኢሴይ",
|
||||||
choice: "ምርጫ",
|
choice: "ምርጫ",
|
||||||
|
both: "ሁለቱም",
|
||||||
offline: "ከመስመር ውጪ",
|
offline: "ከመስመር ውጪ",
|
||||||
online: "በመስመር",
|
online: "በመስመር",
|
||||||
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
||||||
@@ -309,6 +310,7 @@ export const am: Translations = {
|
|||||||
formType: {
|
formType: {
|
||||||
ESSAY: "ኢሴይ",
|
ESSAY: "ኢሴይ",
|
||||||
CHOICE: "ምርጫ",
|
CHOICE: "ምርጫ",
|
||||||
|
BOTH: "ሁለቱም",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
OFFLINE: "ከመስመር ውጪ",
|
OFFLINE: "ከመስመር ውጪ",
|
||||||
@@ -1007,7 +1009,10 @@ export const am: Translations = {
|
|||||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||||
reject: "አትቀበል",
|
reject: "አትቀበል",
|
||||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||||
|
recordExamOutcome: "የፈተና ውጤት መዝግብ",
|
||||||
confirmPayment: "ክፍያ አረጋግጥ",
|
confirmPayment: "ክፍያ አረጋግጥ",
|
||||||
|
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
|
||||||
|
issueCertificate: "ሰርተፍኬት ስጥ",
|
||||||
print: "ሰነድ አትም",
|
print: "ሰነድ አትም",
|
||||||
copyLink: "አገናኝ ቅዳ",
|
copyLink: "አገናኝ ቅዳ",
|
||||||
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",
|
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ export const en = {
|
|||||||
oral: 'Oral',
|
oral: 'Oral',
|
||||||
essay: 'Essay',
|
essay: 'Essay',
|
||||||
choice: 'Choice',
|
choice: 'Choice',
|
||||||
|
both: 'Both',
|
||||||
offline: 'Offline',
|
offline: 'Offline',
|
||||||
online: 'Online',
|
online: 'Online',
|
||||||
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
||||||
@@ -307,6 +308,7 @@ export const en = {
|
|||||||
formType: {
|
formType: {
|
||||||
ESSAY: 'Essay',
|
ESSAY: 'Essay',
|
||||||
CHOICE: 'Choice',
|
CHOICE: 'Choice',
|
||||||
|
BOTH: 'Both',
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
OFFLINE: 'Offline',
|
OFFLINE: 'Offline',
|
||||||
@@ -1016,7 +1018,10 @@ export const en = {
|
|||||||
requestAdjustment: 'Request adjustment',
|
requestAdjustment: 'Request adjustment',
|
||||||
reject: 'Reject',
|
reject: 'Reject',
|
||||||
scheduleExam: 'Schedule exam',
|
scheduleExam: 'Schedule exam',
|
||||||
|
recordExamOutcome: 'Record exam outcome',
|
||||||
confirmPayment: 'Confirm payment',
|
confirmPayment: 'Confirm payment',
|
||||||
|
scheduleIssuance: 'Schedule pickup',
|
||||||
|
issueCertificate: 'Issue certificate',
|
||||||
print: 'Print dossier',
|
print: 'Print dossier',
|
||||||
copyLink: 'Copy link',
|
copyLink: 'Copy link',
|
||||||
downloadDocuments: 'Download all documents',
|
downloadDocuments: 'Download all documents',
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
conditionHolds,
|
conditionHolds,
|
||||||
|
useGetActiveDepartmentsQuery,
|
||||||
|
useGetRanksQuery,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
|
type Bilingual,
|
||||||
|
type Department,
|
||||||
type FormFieldConfig,
|
type FormFieldConfig,
|
||||||
type FormSectionConfig,
|
type FormSectionConfig,
|
||||||
|
type Rank,
|
||||||
type Vessel,
|
type Vessel,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
||||||
@@ -82,6 +87,41 @@ export function fillFromVessel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for a SELECT field.
|
||||||
|
*
|
||||||
|
* A department/rank field's seed `options` are a label cache that goes stale
|
||||||
|
* the moment a backoffice admin adds a department or rank — the field's
|
||||||
|
* value is already resolved server-side (profile department, or
|
||||||
|
* `eligibility.nextRank`), correct either way, but a value missing from a
|
||||||
|
* stale cache renders as a blank Select. Live-fetched departments/ranks take
|
||||||
|
* over the labels for these two `source`s; the seed's own `options` still
|
||||||
|
* cover every other SELECT unchanged.
|
||||||
|
*/
|
||||||
|
function selectOptions(
|
||||||
|
field: FormFieldConfig,
|
||||||
|
currentValue: string | undefined,
|
||||||
|
departments: Department[] | undefined,
|
||||||
|
ranks: Rank[],
|
||||||
|
localized: (v?: Bilingual) => string,
|
||||||
|
): { value: string; label: string }[] {
|
||||||
|
if (field.source === 'profile.seafarerDepartment' && departments) {
|
||||||
|
return departments.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||||
|
}
|
||||||
|
if (field.source === 'eligibility.nextRank') {
|
||||||
|
const options = ranks.map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||||
|
// The resolved rank might not be on THIS field's ladder (rank/rankEngine,
|
||||||
|
// proficiencyDeck/Engine share one `eligibility.nextRank` source but only
|
||||||
|
// one is ever populated) — still show it rather than a blank Select.
|
||||||
|
if (currentValue && !options.some((o) => o.value === currentValue)) {
|
||||||
|
const known = ranks.find((r) => r.key === currentValue);
|
||||||
|
options.push({ value: currentValue, label: known ? localized(known.name) : currentValue });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
return (field.options ?? []).map((o) => ({ value: o.value, label: localized(o.label) }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders one form section from the license type's configuration.
|
* Renders one form section from the license type's configuration.
|
||||||
*
|
*
|
||||||
@@ -105,6 +145,21 @@ export function ConfigDrivenSection({
|
|||||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A department/rank SELECT ships with a hardcoded `options` label list in
|
||||||
|
// the seed, so a department or rank added later in the backoffice has no
|
||||||
|
// label there and would render as a blank Select even though the field's
|
||||||
|
// value (resolved server-side) is correct. Skipped when the section has
|
||||||
|
// neither kind of field, so most sections never pay for these two queries.
|
||||||
|
const needsDepartmentLabels = fields.some(
|
||||||
|
(f) => f.source === 'profile.seafarerDepartment',
|
||||||
|
);
|
||||||
|
const needsRankLabels = fields.some((f) => f.source === 'eligibility.nextRank');
|
||||||
|
const { data: departments } = useGetActiveDepartmentsQuery(undefined, {
|
||||||
|
skip: !needsDepartmentLabels,
|
||||||
|
});
|
||||||
|
const { data: rankRes } = useGetRanksQuery(undefined, { skip: !needsRankLabels });
|
||||||
|
const ranks = rankRes?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid>
|
<Grid>
|
||||||
{fields.map((field) => {
|
{fields.map((field) => {
|
||||||
@@ -183,10 +238,7 @@ export function ConfigDrivenSection({
|
|||||||
) : field.type === 'SELECT' ? (
|
) : field.type === 'SELECT' ? (
|
||||||
<Select
|
<Select
|
||||||
{...common}
|
{...common}
|
||||||
data={(field.options ?? []).map((o) => ({
|
data={selectOptions(field, value as string | undefined, departments, ranks, localized)}
|
||||||
value: o.value,
|
|
||||||
label: localized(o.label),
|
|
||||||
}))}
|
|
||||||
value={(value as string) ?? null}
|
value={(value as string) ?? null}
|
||||||
onChange={(v) => onChange(field.key, v)}
|
onChange={(v) => onChange(field.key, v)}
|
||||||
clearable={!field.required}
|
clearable={!field.required}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||||
SEAFARER_REGISTRATION_SECTIONS,
|
SEAFARER_REGISTRATION_SECTIONS,
|
||||||
displaySeafarerAnswer,
|
displaySeafarerAnswer,
|
||||||
|
useGetActiveDepartmentsQuery,
|
||||||
|
useLocalized,
|
||||||
type Attachment,
|
type Attachment,
|
||||||
type SaveSeafarerRegistration,
|
type SaveSeafarerRegistration,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
@@ -16,6 +18,10 @@ export function RegistrationSummary({
|
|||||||
answers: SaveSeafarerRegistration;
|
answers: SaveSeafarerRegistration;
|
||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
}) {
|
}) {
|
||||||
|
const localized = useLocalized();
|
||||||
|
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||||
|
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
|
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
|
||||||
@@ -35,7 +41,9 @@ export function RegistrationSummary({
|
|||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text>
|
<Text size="sm">
|
||||||
|
{displaySeafarerAnswer(field, answers[field], departmentOptions)}
|
||||||
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
GENDER_OPTIONS,
|
GENDER_OPTIONS,
|
||||||
HAIR_COLOR_OPTIONS,
|
HAIR_COLOR_OPTIONS,
|
||||||
MARITAL_STATUS_OPTIONS,
|
MARITAL_STATUS_OPTIONS,
|
||||||
|
useGetActiveDepartmentsQuery,
|
||||||
|
useLocalized,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
DateField,
|
DateField,
|
||||||
@@ -72,8 +74,21 @@ export function IdentityDetailsStep(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step 2 — Identity, Address and Physical Characteristics. */
|
/**
|
||||||
|
* Step 2 — Identity, Address and Physical Characteristics.
|
||||||
|
*
|
||||||
|
* The department list is backoffice-managed (see the Ranks & Departments
|
||||||
|
* configuration tab), so this fetches the live set rather than a fixed
|
||||||
|
* three — falling back to it only until the query resolves, so the field
|
||||||
|
* is never an empty flash.
|
||||||
|
*/
|
||||||
export function ApplicantDetailsStep(p: StepProps) {
|
export function ApplicantDetailsStep(p: StepProps) {
|
||||||
|
const localized = useLocalized();
|
||||||
|
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||||
|
const departmentOptions =
|
||||||
|
departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ??
|
||||||
|
DEPARTMENT_OPTIONS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<SectionTitle title="Identity" />
|
<SectionTitle title="Identity" />
|
||||||
@@ -94,7 +109,7 @@ export function ApplicantDetailsStep(p: StepProps) {
|
|||||||
name="department"
|
name="department"
|
||||||
label="Department"
|
label="Department"
|
||||||
required
|
required
|
||||||
options={DEPARTMENT_OPTIONS}
|
options={departmentOptions}
|
||||||
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { resolveSessionContext } from "../session";
|
|||||||
export const BASE_API_URL =
|
export const BASE_API_URL =
|
||||||
(import.meta as { env?: Record<string, string> }).env?.[
|
(import.meta as { env?: Record<string, string> }).env?.[
|
||||||
"VITE_BASE_API_URL"
|
"VITE_BASE_API_URL"
|
||||||
] ?? "http://localhost:3001/api";
|
] ?? "http://localhost:3000/api";
|
||||||
|
|
||||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||||
let _onAuthFailure: (() => void) | null = null;
|
let _onAuthFailure: (() => void) | null = null;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ApplicationPayment,
|
ApplicationPayment,
|
||||||
ApplicationStaff,
|
ApplicationStaff,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
Department,
|
||||||
DocumentRequirement,
|
DocumentRequirement,
|
||||||
FormSchemaPalette,
|
FormSchemaPalette,
|
||||||
FormSectionConfig,
|
FormSectionConfig,
|
||||||
@@ -27,6 +28,8 @@ import type {
|
|||||||
Paginated,
|
Paginated,
|
||||||
QueueCounts,
|
QueueCounts,
|
||||||
QueueFilter,
|
QueueFilter,
|
||||||
|
Rank,
|
||||||
|
RankCertificateCategory,
|
||||||
RemarkTargetType,
|
RemarkTargetType,
|
||||||
SavedQueueView,
|
SavedQueueView,
|
||||||
SchemaIssue,
|
SchemaIssue,
|
||||||
@@ -72,6 +75,8 @@ const TAGS = [
|
|||||||
'SavedView',
|
'SavedView',
|
||||||
'LicenseTemplate',
|
'LicenseTemplate',
|
||||||
'DocumentRequirement',
|
'DocumentRequirement',
|
||||||
|
'Department',
|
||||||
|
'Rank',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||||
@@ -254,6 +259,75 @@ export const licensingApi = baseApi
|
|||||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// --------------------------------------------------- departments & ranks
|
||||||
|
/** Every department, for the admin editor. */
|
||||||
|
getDepartments: builder.query<Paginated<Department>, void>({
|
||||||
|
query: () => ({ url: '/departments' }),
|
||||||
|
providesTags: () => [listTag('Department')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Active departments only — the applicant-facing picker. */
|
||||||
|
getActiveDepartments: builder.query<Department[], void>({
|
||||||
|
query: () => ({ url: '/departments/active/list' }),
|
||||||
|
providesTags: () => [listTag('Department')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDepartment: builder.mutation<
|
||||||
|
Department,
|
||||||
|
Partial<Department> & { code: string; name: Department['name'] }
|
||||||
|
>({
|
||||||
|
query: (body) => ({ url: '/departments', method: 'POST', body }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateDepartment: builder.mutation<Department, { id: string } & Partial<Department>>({
|
||||||
|
query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteDepartment: builder.mutation<unknown, string>({
|
||||||
|
query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Every rank, for the admin editor to filter/group by department client-side. */
|
||||||
|
getRanks: builder.query<Paginated<Rank>, void>({
|
||||||
|
query: () => ({ url: '/ranks' }),
|
||||||
|
providesTags: () => [listTag('Rank')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** One department's ladder for a category, ordered — the applicant wizard's rank picker. */
|
||||||
|
getRankLadder: builder.query<
|
||||||
|
Rank[],
|
||||||
|
{ departmentId: string; certificateCategory: RankCertificateCategory }
|
||||||
|
>({
|
||||||
|
query: (params) => ({ url: '/ranks/ladder', params }),
|
||||||
|
providesTags: () => [listTag('Rank')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
createRank: builder.mutation<
|
||||||
|
Rank,
|
||||||
|
Partial<Rank> & {
|
||||||
|
departmentId: string;
|
||||||
|
certificateCategory: RankCertificateCategory;
|
||||||
|
key: string;
|
||||||
|
name: Rank['name'];
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
query: (body) => ({ url: '/ranks', method: 'POST', body }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateRank: builder.mutation<Rank, { id: string } & Partial<Rank>>({
|
||||||
|
query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteRank: builder.mutation<unknown, string>({
|
||||||
|
query: (id) => ({ url: `/ranks/${id}`, method: 'DELETE' }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
||||||
|
}),
|
||||||
|
|
||||||
// -------------------------------------------------------- application
|
// -------------------------------------------------------- application
|
||||||
createApplication: builder.mutation<
|
createApplication: builder.mutation<
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
@@ -641,6 +715,8 @@ export const licensingApi = baseApi
|
|||||||
LicenseTemplate,
|
LicenseTemplate,
|
||||||
{
|
{
|
||||||
licenseTypeId: string;
|
licenseTypeId: string;
|
||||||
|
/** Scopes the draft to one rank's certificate. Omit for the type's default design. */
|
||||||
|
rankId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
hbsSource?: string;
|
hbsSource?: string;
|
||||||
pageOptions?: TemplatePageOptions;
|
pageOptions?: TemplatePageOptions;
|
||||||
@@ -654,6 +730,7 @@ export const licensingApi = baseApi
|
|||||||
LicenseTemplate,
|
LicenseTemplate,
|
||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
|
rankId?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
hbsSource?: string;
|
hbsSource?: string;
|
||||||
pageOptions?: TemplatePageOptions;
|
pageOptions?: TemplatePageOptions;
|
||||||
@@ -919,6 +996,16 @@ export const {
|
|||||||
useCreateDocumentRequirementMutation,
|
useCreateDocumentRequirementMutation,
|
||||||
useUpdateDocumentRequirementMutation,
|
useUpdateDocumentRequirementMutation,
|
||||||
useDeleteDocumentRequirementMutation,
|
useDeleteDocumentRequirementMutation,
|
||||||
|
useGetDepartmentsQuery,
|
||||||
|
useGetActiveDepartmentsQuery,
|
||||||
|
useCreateDepartmentMutation,
|
||||||
|
useUpdateDepartmentMutation,
|
||||||
|
useDeleteDepartmentMutation,
|
||||||
|
useGetRanksQuery,
|
||||||
|
useGetRankLadderQuery,
|
||||||
|
useCreateRankMutation,
|
||||||
|
useUpdateRankMutation,
|
||||||
|
useDeleteRankMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
useGetLicenseTypeRequirementsQuery,
|
useGetLicenseTypeRequirementsQuery,
|
||||||
useCreateApplicationMutation,
|
useCreateApplicationMutation,
|
||||||
|
|||||||
@@ -558,14 +558,25 @@ export function validateSections(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Evaluates a config condition against the current form answers. */
|
/** Evaluates a config condition against the current form answers. */
|
||||||
|
interface ConditionLike {
|
||||||
|
field?: string;
|
||||||
|
equals?: unknown;
|
||||||
|
notEquals?: unknown;
|
||||||
|
in?: (string | number)[];
|
||||||
|
isSet?: boolean;
|
||||||
|
/** Holds when ANY listed sub-condition holds — see FieldCondition.anyOf. */
|
||||||
|
anyOf?: ConditionLike[];
|
||||||
|
}
|
||||||
|
|
||||||
export function conditionHolds(
|
export function conditionHolds(
|
||||||
condition:
|
condition: ConditionLike | undefined | null,
|
||||||
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
|
|
||||||
| undefined
|
|
||||||
| null,
|
|
||||||
formData: Record<string, Record<string, unknown>>,
|
formData: Record<string, Record<string, unknown>>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!condition?.field) return true;
|
if (!condition) return true;
|
||||||
|
if (condition.anyOf) {
|
||||||
|
return condition.anyOf.some((sub) => conditionHolds(sub, formData));
|
||||||
|
}
|
||||||
|
if (!condition.field) return true;
|
||||||
const value = condition.field
|
const value = condition.field
|
||||||
.split('.')
|
.split('.')
|
||||||
.reduce<unknown>(
|
.reduce<unknown>(
|
||||||
|
|||||||
@@ -67,11 +67,18 @@ export type FormFieldType =
|
|||||||
| "TIN";
|
| "TIN";
|
||||||
|
|
||||||
export interface FieldCondition {
|
export interface FieldCondition {
|
||||||
field: string;
|
/** Omitted when `anyOf` is used instead — see below. */
|
||||||
|
field?: string;
|
||||||
equals?: string | number | boolean;
|
equals?: string | number | boolean;
|
||||||
notEquals?: string | number | boolean;
|
notEquals?: string | number | boolean;
|
||||||
in?: (string | number)[];
|
in?: (string | number)[];
|
||||||
isSet?: boolean;
|
isSet?: boolean;
|
||||||
|
/**
|
||||||
|
* Holds when ANY listed condition holds — for a value that can live on one
|
||||||
|
* of several mutually-exclusive fields (e.g. a rank split by department).
|
||||||
|
* `field`/`equals`/etc are ignored when this is present.
|
||||||
|
*/
|
||||||
|
anyOf?: FieldCondition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormFieldConfig {
|
export interface FormFieldConfig {
|
||||||
@@ -565,19 +572,50 @@ export interface TemplateFieldPlacement {
|
|||||||
/** Variable rendered here, or null when the block carries literal `text`. */
|
/** Variable rendered here, or null when the block carries literal `text`. */
|
||||||
variable: string | null;
|
variable: string | null;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
/** Renders as `<img>` when "image" — see TemplateVariable.kind. */
|
||||||
|
type?: "text" | "image";
|
||||||
xPct: number;
|
xPct: number;
|
||||||
yPct: number;
|
yPct: number;
|
||||||
widthPct: number;
|
widthPct: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontWeight?: "normal" | "bold";
|
fontWeight?: "normal" | "bold";
|
||||||
align?: "left" | "center" | "right";
|
fontStyle?: "normal" | "italic";
|
||||||
|
align?: "left" | "center" | "right" | "justify";
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A certificate design authored in the backoffice. */
|
/** A certificate design authored in the backoffice. */
|
||||||
|
/** An STCW seafarer department (Deck, Engine, Catering), backoffice-managed. */
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
/** Matches the ESeafarerDepartment value stored elsewhere, e.g. "DECK". */
|
||||||
|
code: string;
|
||||||
|
name: Bilingual;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RankCertificateCategory = "COC" | "COP";
|
||||||
|
|
||||||
|
/** One rung of a CoC/CoP ladder for a department. */
|
||||||
|
export interface Rank {
|
||||||
|
id: string;
|
||||||
|
departmentId: string;
|
||||||
|
certificateCategory: RankCertificateCategory;
|
||||||
|
/** Value stored on License.rank / Certification.rankKey, e.g. "CHIEF_MATE". */
|
||||||
|
key: string;
|
||||||
|
name: Bilingual;
|
||||||
|
/** Rung position within its department+category ladder. 0 is the entry rank. */
|
||||||
|
ladderOrder: number;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LicenseTemplate {
|
export interface LicenseTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
licenseTypeId: string;
|
licenseTypeId: string;
|
||||||
|
/** Scopes this design to one rank's certificate. Null = the type's default. */
|
||||||
|
rankId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
version: number;
|
version: number;
|
||||||
hbsSource: string;
|
hbsSource: string;
|
||||||
@@ -604,6 +642,8 @@ export interface LicenseTemplate {
|
|||||||
export interface TemplateVariable {
|
export interface TemplateVariable {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** "image" means the value is a data URI to place as `<img>`, not text. */
|
||||||
|
kind?: "text" | "image";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Paginated<T> {
|
export interface Paginated<T> {
|
||||||
|
|||||||
@@ -210,14 +210,22 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
|
|||||||
bloodType: BLOOD_TYPE_OPTIONS,
|
bloodType: BLOOD_TYPE_OPTIONS,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Display value for one answer: option label for enums, "—" when blank. */
|
/**
|
||||||
|
* Display value for one answer: option label for enums, "—" when blank.
|
||||||
|
*
|
||||||
|
* `departmentOptions` overrides the hardcoded `DEPARTMENT_OPTIONS` fallback
|
||||||
|
* for the `department` field — the live list from `GET /departments`, so a
|
||||||
|
* department added in the backoffice after this constants file was written
|
||||||
|
* still gets its name instead of falling back to the raw code.
|
||||||
|
*/
|
||||||
export function displaySeafarerAnswer(
|
export function displaySeafarerAnswer(
|
||||||
field: keyof SeafarerRegistrationAnswers,
|
field: keyof SeafarerRegistrationAnswers,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
|
departmentOptions?: { value: string; label: string }[],
|
||||||
): string {
|
): string {
|
||||||
if (value === null || value === undefined || value === '') return '—';
|
if (value === null || value === undefined || value === '') return '—';
|
||||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||||
const options = OPTION_LABELS[field];
|
const options = field === 'department' && departmentOptions ? departmentOptions : OPTION_LABELS[field];
|
||||||
if (options) return options.find((o) => o.value === value)?.label ?? String(value);
|
if (options) return options.find((o) => o.value === value)?.label ?? String(value);
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user