mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
@@ -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,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) =>
|
||||||
|
|||||||
@@ -392,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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -24,7 +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 { RANK_KEY_OPTIONS } from "../../../certification/types/certification";
|
import { useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
||||||
import {
|
import {
|
||||||
useGetExamsQuery,
|
useGetExamsQuery,
|
||||||
useCreateExamMutation,
|
useCreateExamMutation,
|
||||||
@@ -364,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();
|
||||||
@@ -391,7 +394,7 @@ export function ExamPage() {
|
|||||||
const certOptions = certifications
|
const certOptions = certifications
|
||||||
.filter((c) => c.isActive)
|
.filter((c) => c.isActive)
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label;
|
const rank = c.rankKey ? rankLabelByKey.get(c.rankKey) : undefined;
|
||||||
return {
|
return {
|
||||||
value: c.id,
|
value: c.id,
|
||||||
label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale],
|
label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale],
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,7 @@ import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } fr
|
|||||||
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||||
|
isEthiopianNationality,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
type Attachment,
|
type Attachment,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
@@ -10,22 +11,25 @@ import {
|
|||||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
/** The document slots a registration asks for. */
|
/** The document slots a registration asks for. */
|
||||||
export function documentSlots(passportDeclared: boolean) {
|
export function documentSlots(passportDeclared: boolean, nationality?: string | null) {
|
||||||
|
const ethiopian = isEthiopianNationality(nationality);
|
||||||
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
|
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
|
||||||
...d,
|
...d,
|
||||||
isRequired: d.required === 'passport' ? passportDeclared : d.required,
|
isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
|
||||||
})).filter((d) => d.required !== 'passport' || passportDeclared);
|
})).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegistrationDocuments({
|
export function RegistrationDocuments({
|
||||||
registrationId,
|
registrationId,
|
||||||
passportDeclared,
|
passportDeclared,
|
||||||
|
nationality,
|
||||||
attachments,
|
attachments,
|
||||||
readOnly,
|
readOnly,
|
||||||
onUploaded,
|
onUploaded,
|
||||||
}: {
|
}: {
|
||||||
registrationId: string;
|
registrationId: string;
|
||||||
passportDeclared: boolean;
|
passportDeclared: boolean;
|
||||||
|
nationality?: string | null;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onUploaded: () => void;
|
onUploaded: () => void;
|
||||||
@@ -62,7 +66,7 @@ export function RegistrationDocuments({
|
|||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{documentSlots(passportDeclared).map((slot) => {
|
{documentSlots(passportDeclared, nationality).map((slot) => {
|
||||||
const existing = attachments.find((a) => a.documentKey === slot.key);
|
const existing = attachments.find((a) => a.documentKey === slot.key);
|
||||||
const uploaded = Boolean(existing?.files?.length);
|
const uploaded = Boolean(existing?.files?.length);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,9 @@ import {
|
|||||||
GENDER_OPTIONS,
|
GENDER_OPTIONS,
|
||||||
HAIR_COLOR_OPTIONS,
|
HAIR_COLOR_OPTIONS,
|
||||||
MARITAL_STATUS_OPTIONS,
|
MARITAL_STATUS_OPTIONS,
|
||||||
|
isEthiopianNationality,
|
||||||
|
useGetActiveDepartmentsQuery,
|
||||||
|
useLocalized,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
DateField,
|
DateField,
|
||||||
@@ -42,6 +45,7 @@ function SectionTitle({ title, description }: { title: string; description?: str
|
|||||||
export function IdentityDetailsStep(
|
export function IdentityDetailsStep(
|
||||||
p: StepProps & { account: { email?: string; phoneNumber?: string } },
|
p: StepProps & { account: { email?: string; phoneNumber?: string } },
|
||||||
) {
|
) {
|
||||||
|
const ethiopian = isEthiopianNationality(p.form.nationality);
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<SectionTitle title="Contact Details" />
|
<SectionTitle title="Contact Details" />
|
||||||
@@ -66,26 +70,55 @@ export function IdentityDetailsStep(
|
|||||||
<DateField {...p} name="dateOfBirth" label="Date of Birth" required />
|
<DateField {...p} name="dateOfBirth" label="Date of Birth" required />
|
||||||
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
|
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
|
||||||
<NationalityField {...p} name="nationality" label="Nationality" required />
|
<NationalityField {...p} name="nationality" label="Nationality" required />
|
||||||
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
|
{ethiopian ? (
|
||||||
|
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
{...p}
|
||||||
|
name="passportNumber"
|
||||||
|
label="Passport Number"
|
||||||
|
required
|
||||||
|
maxLength={32}
|
||||||
|
description="Required for non-Ethiopian applicants in place of a National ID."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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;
|
||||||
|
// Non-Ethiopians already declare their passport number as their primary ID
|
||||||
|
// on the Identity step — asking again here would just duplicate the field.
|
||||||
|
const ethiopian = isEthiopianNationality(p.form.nationality);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<SectionTitle title="Identity" />
|
<SectionTitle title="Identity" />
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
|
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
|
||||||
<TextField
|
{ethiopian && (
|
||||||
{...p}
|
<TextField
|
||||||
name="passportNumber"
|
{...p}
|
||||||
label="Passport Number"
|
name="passportNumber"
|
||||||
maxLength={32}
|
label="Passport Number"
|
||||||
description="Required later for international sea service; optional at registration."
|
maxLength={32}
|
||||||
/>
|
description="Required later for international sea service; optional at registration."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{p.form.passportNumber && (
|
{p.form.passportNumber && (
|
||||||
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
|
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
|
||||||
)}
|
)}
|
||||||
@@ -94,7 +127,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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -8,13 +9,14 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Stepper,
|
Stepper,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil, IconTrash } from '@tabler/icons-react';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
PHYSICAL_BOUNDS,
|
PHYSICAL_BOUNDS,
|
||||||
@@ -23,6 +25,8 @@ import {
|
|||||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
extractValidationIssues,
|
extractValidationIssues,
|
||||||
|
isEthiopianNationality,
|
||||||
|
useCancelSeafarerRegistrationMutation,
|
||||||
useGetAttachmentsQuery,
|
useGetAttachmentsQuery,
|
||||||
useGetMySeafarerRegistrationQuery,
|
useGetMySeafarerRegistrationQuery,
|
||||||
useSaveSeafarerRegistrationMutation,
|
useSaveSeafarerRegistrationMutation,
|
||||||
@@ -48,15 +52,26 @@ const STEPS = [
|
|||||||
{ label: 'Review', description: 'Check & submit' },
|
{ label: 'Review', description: 'Check & submit' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
|
/**
|
||||||
|
* Which answers each step must have before "Continue" — mirrors the API's
|
||||||
|
* submission check. National ID vs Passport Number depends on the declared
|
||||||
|
* nationality, so that slot is added dynamically in `requiredForStep`.
|
||||||
|
*/
|
||||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
|
||||||
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||||
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||||
[],
|
[],
|
||||||
['declarationAccepted'],
|
['declarationAccepted'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Ethiopians must give a National ID; everyone else must give a Passport Number instead. */
|
||||||
|
function requiredForStep(index: number, nationality: string | null | undefined): AnswerKey[] {
|
||||||
|
const base = REQUIRED_BY_STEP[index] ?? [];
|
||||||
|
if (index !== 0) return base;
|
||||||
|
return [...base, isEthiopianNationality(nationality) ? 'nationalIdNumber' : 'passportNumber'];
|
||||||
|
}
|
||||||
|
|
||||||
const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
|
const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
|
||||||
|
|
||||||
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
|
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
|
||||||
@@ -119,15 +134,18 @@ function withProfileDefaults(
|
|||||||
* still missing. A submitted registration opens to a read-only summary.
|
* still missing. A submitted registration opens to a read-only summary.
|
||||||
*/
|
*/
|
||||||
export function SeafarerRegistrationPage() {
|
export function SeafarerRegistrationPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const accountUser = useAppSelector((state) => state.auth.user);
|
const accountUser = useAppSelector((state) => state.auth.user);
|
||||||
const { profile } = useCurrentProfile();
|
const { profile } = useCurrentProfile();
|
||||||
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
|
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
|
||||||
const registration = data?.registration ?? null;
|
const registration = data?.registration ?? null;
|
||||||
|
|
||||||
const [start] = useStartSeafarerRegistrationMutation();
|
const [start] = useStartSeafarerRegistrationMutation();
|
||||||
|
const [cancelDraft, { isLoading: cancelling }] = useCancelSeafarerRegistrationMutation();
|
||||||
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
|
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
|
||||||
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
||||||
const [startError, setStartError] = useState<string | null>(null);
|
const [startError, setStartError] = useState<string | null>(null);
|
||||||
|
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
||||||
const started = useRef(false);
|
const started = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -204,7 +222,7 @@ export function SeafarerRegistrationPage() {
|
|||||||
|
|
||||||
function validateStep(index: number): boolean {
|
function validateStep(index: number): boolean {
|
||||||
const found: Partial<Record<AnswerKey, string>> = {};
|
const found: Partial<Record<AnswerKey, string>> = {};
|
||||||
for (const key of REQUIRED_BY_STEP[index] ?? []) {
|
for (const key of requiredForStep(index, form.nationality)) {
|
||||||
if (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
|
if (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
|
||||||
}
|
}
|
||||||
if (index === 1) {
|
if (index === 1) {
|
||||||
@@ -232,7 +250,7 @@ export function SeafarerRegistrationPage() {
|
|||||||
}
|
}
|
||||||
if (index === 3) {
|
if (index === 3) {
|
||||||
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
|
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
|
||||||
const missing = documentSlots(Boolean(form.passportNumber))
|
const missing = documentSlots(Boolean(form.passportNumber), form.nationality)
|
||||||
.filter((d) => d.isRequired && !supplied.has(d.key))
|
.filter((d) => d.isRequired && !supplied.has(d.key))
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
@@ -302,6 +320,19 @@ export function SeafarerRegistrationPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
if (!registration) return;
|
||||||
|
try {
|
||||||
|
await cancelDraft(registration.id).unwrap();
|
||||||
|
notifications.show({ color: 'teal', title: 'Draft discarded', message: 'Nothing was saved.' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({ color: 'red', title: 'Could not discard the draft', message: extractErrorMessage(err) });
|
||||||
|
} finally {
|
||||||
|
setConfirmingCancel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stepProps = { form, set, errors, disabled: readOnly };
|
const stepProps = { form, set, errors, disabled: readOnly };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -319,11 +350,24 @@ export function SeafarerRegistrationPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
{showSummary && !readOnly && (
|
<Group gap="xs">
|
||||||
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
{registration.status === 'DRAFT' && (
|
||||||
Edit details
|
<Button
|
||||||
</Button>
|
size="xs"
|
||||||
)}
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconTrash size={14} />}
|
||||||
|
onClick={() => setConfirmingCancel(true)}
|
||||||
|
>
|
||||||
|
Cancel & discard draft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showSummary && !readOnly && (
|
||||||
|
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
||||||
|
Edit details
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{registration.status === 'APPROVED' && (
|
{registration.status === 'APPROVED' && (
|
||||||
@@ -386,6 +430,7 @@ export function SeafarerRegistrationPage() {
|
|||||||
<RegistrationDocuments
|
<RegistrationDocuments
|
||||||
registrationId={registration.id}
|
registrationId={registration.id}
|
||||||
passportDeclared={Boolean(form.passportNumber)}
|
passportDeclared={Boolean(form.passportNumber)}
|
||||||
|
nationality={form.nationality}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onUploaded={refetchAttachments}
|
onUploaded={refetchAttachments}
|
||||||
@@ -426,6 +471,23 @@ export function SeafarerRegistrationPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal opened={confirmingCancel} onClose={() => setConfirmingCancel(false)} title="Discard this draft?" centered>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
Everything you have entered will be deleted, including any documents already uploaded. This cannot be
|
||||||
|
undone. You can start a new registration at any time.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setConfirmingCancel(false)} disabled={cancelling}>
|
||||||
|
Keep draft
|
||||||
|
</Button>
|
||||||
|
<Button color="red" loading={cancelling} onClick={handleCancel}>
|
||||||
|
Discard draft
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,6 +298,22 @@ function SeaServiceTab() {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Shown once the field has something in it — a blank required field is left
|
||||||
|
// to the button being disabled, same as the rest of the form; only an
|
||||||
|
// actual too-short value gets called out.
|
||||||
|
const vesselNameError =
|
||||||
|
form.vesselName && form.vesselName.trim().length <= 1
|
||||||
|
? t('seaRecords.seaService.fields.vesselNameTooShort', {
|
||||||
|
defaultValue: 'Must be at least 2 characters.',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const rankError =
|
||||||
|
form.rank && form.rank.trim().length <= 1
|
||||||
|
? t('seaRecords.seaService.fields.rankTooShort', {
|
||||||
|
defaultValue: 'Must be at least 2 characters.',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const valid =
|
const valid =
|
||||||
form.vesselName.trim().length > 1 &&
|
form.vesselName.trim().length > 1 &&
|
||||||
form.rank.trim().length > 1 &&
|
form.rank.trim().length > 1 &&
|
||||||
@@ -391,6 +407,7 @@ function SeaServiceTab() {
|
|||||||
required
|
required
|
||||||
value={form.vesselName}
|
value={form.vesselName}
|
||||||
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
|
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
|
||||||
|
error={vesselNameError}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('seaRecords.seaService.fields.imoNumber')}
|
label={t('seaRecords.seaService.fields.imoNumber')}
|
||||||
@@ -418,9 +435,13 @@ function SeaServiceTab() {
|
|||||||
</Group>
|
</Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('seaRecords.seaService.fields.rank')}
|
label={t('seaRecords.seaService.fields.rank')}
|
||||||
|
description={t('seaRecords.seaService.fields.rankHint', {
|
||||||
|
defaultValue: 'Must be at least 2 characters, e.g. "AB" or "2nd Officer".',
|
||||||
|
})}
|
||||||
required
|
required
|
||||||
value={form.rank}
|
value={form.rank}
|
||||||
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
||||||
|
error={rankError}
|
||||||
/>
|
/>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<AmharicDatePicker
|
<AmharicDatePicker
|
||||||
|
|||||||
@@ -572,12 +572,15 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,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> {
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export const seafarerRegistrationApi = baseApi
|
|||||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
cancelSeafarerRegistration: builder.mutation<void, string>({
|
||||||
|
query: (id) => ({ url: `/seafarer-registrations/${id}`, method: 'DELETE' }),
|
||||||
|
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||||
|
}),
|
||||||
|
|
||||||
saveSeafarerRegistration: builder.mutation<
|
saveSeafarerRegistration: builder.mutation<
|
||||||
SeafarerRegistration,
|
SeafarerRegistration,
|
||||||
{ id: string; body: SaveSeafarerRegistration }
|
{ id: string; body: SaveSeafarerRegistration }
|
||||||
@@ -108,6 +113,7 @@ export const seafarerRegistrationApi = baseApi
|
|||||||
export const {
|
export const {
|
||||||
useGetMySeafarerRegistrationQuery,
|
useGetMySeafarerRegistrationQuery,
|
||||||
useStartSeafarerRegistrationMutation,
|
useStartSeafarerRegistrationMutation,
|
||||||
|
useCancelSeafarerRegistrationMutation,
|
||||||
useSaveSeafarerRegistrationMutation,
|
useSaveSeafarerRegistrationMutation,
|
||||||
useSubmitSeafarerRegistrationMutation,
|
useSubmitSeafarerRegistrationMutation,
|
||||||
useListSeafarerRegistrationsQuery,
|
useListSeafarerRegistrationsQuery,
|
||||||
|
|||||||
@@ -64,13 +64,28 @@ export const PHYSICAL_BOUNDS = {
|
|||||||
weightKg: { min: 30, max: 250 },
|
weightKg: { min: 30, max: 250 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the platform spells Ethiopia in the `nationality` free-text field
|
||||||
|
* (CountrySelect's country name — matches the API's ETHIOPIAN_NATIONALITY).
|
||||||
|
*/
|
||||||
|
export const ETHIOPIAN_NATIONALITY = 'Ethiopia';
|
||||||
|
|
||||||
|
/** Whether a declared nationality is Ethiopian — drives National ID vs Passport requirements. */
|
||||||
|
export function isEthiopianNationality(nationality: string | null | undefined): boolean {
|
||||||
|
return nationality === ETHIOPIAN_NATIONALITY;
|
||||||
|
}
|
||||||
|
|
||||||
/** Upload slots, keyed as the API's submission check expects them. */
|
/** Upload slots, keyed as the API's submission check expects them. */
|
||||||
export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
/** `'passport'`: required only once a passport number is declared. */
|
/**
|
||||||
required: boolean | 'passport';
|
* `'passport'`: required once a passport number is declared (always true
|
||||||
|
* for non-Ethiopians, who must declare one). `'ethiopian'`: required only
|
||||||
|
* for applicants who declared Ethiopian nationality.
|
||||||
|
*/
|
||||||
|
required: boolean | 'passport' | 'ethiopian';
|
||||||
accept?: string;
|
accept?: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
@@ -80,7 +95,7 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
|||||||
required: true,
|
required: true,
|
||||||
accept: 'image/jpeg,image/png',
|
accept: 'image/jpeg,image/png',
|
||||||
},
|
},
|
||||||
{ key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: true },
|
{ key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: 'ethiopian' },
|
||||||
{ key: 'passport', name: 'Passport Copy', required: 'passport' },
|
{ key: 'passport', name: 'Passport Copy', required: 'passport' },
|
||||||
{ key: 'graduation', name: 'Educational Certificate', required: false },
|
{ key: 'graduation', name: 'Educational Certificate', required: false },
|
||||||
{
|
{
|
||||||
@@ -210,14 +225,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