mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 11:21:21 +00:00
Merge remote-tracking branch 'origin/dev' into feature/fayda-auth-and-email-notifications
# Conflicts: # apps/backoffice/src/app/features/user-management/UserManagementPage.tsx
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
ColorInput,
|
||||
Group,
|
||||
NumberInput,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
@@ -70,11 +81,9 @@ export function BlockPropertiesPanel({
|
||||
value={isLiteral ? 'text' : 'variable'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
value === 'text'
|
||||
? { ...block, variable: null, text: block.text ?? '' }
|
||||
: { ...block, variable: variables[0]?.key ?? 'companyName' },
|
||||
)
|
||||
value === 'text'
|
||||
? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' })
|
||||
: selectVariable(variables[0]?.key ?? 'companyName')
|
||||
}
|
||||
data={[
|
||||
{ value: 'variable', label: t('designer.blockVariable', 'Variable') },
|
||||
@@ -94,24 +103,26 @@ export function BlockPropertiesPanel({
|
||||
label={t('designer.blockVariableLabel', 'Variable')}
|
||||
data={variables.map((variable) => ({
|
||||
value: variable.key,
|
||||
label: variable.label,
|
||||
label: variable.kind === 'image' ? `🖼 ${variable.label}` : variable.label,
|
||||
}))}
|
||||
value={block.variable}
|
||||
onChange={(value) => onChange({ ...block, variable: value })}
|
||||
onChange={selectVariable}
|
||||
searchable
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group gap="xs" grow>
|
||||
<NumberInput
|
||||
label={t('designer.blockFontSize', 'Font size')}
|
||||
value={block.fontSize ?? 14}
|
||||
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
|
||||
min={4}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!isImage && (
|
||||
<NumberInput
|
||||
label={t('designer.blockFontSize', 'Font size')}
|
||||
value={block.fontSize ?? 14}
|
||||
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
|
||||
min={4}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
label={t('designer.blockWidth', 'Width (%)')}
|
||||
value={block.widthPct}
|
||||
@@ -149,42 +160,62 @@ export function BlockPropertiesPanel({
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={block.align ?? 'left'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
|
||||
}
|
||||
data={[
|
||||
{ value: 'left', label: t('designer.alignLeft', 'Left') },
|
||||
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
|
||||
{ value: 'right', label: t('designer.alignRight', 'Right') },
|
||||
]}
|
||||
/>
|
||||
{!isImage && (
|
||||
<>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={block.align ?? 'left'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
|
||||
}
|
||||
data={[
|
||||
{ value: 'left', label: t('designer.alignLeft', 'Left') },
|
||||
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
|
||||
{ value: 'right', label: t('designer.alignRight', 'Right') },
|
||||
{ value: 'justify', label: t('designer.alignJustify', 'Justify') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={block.fontWeight ?? 'normal'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] })
|
||||
}
|
||||
data={[
|
||||
{ value: 'normal', label: t('designer.weightNormal', 'Normal') },
|
||||
{ value: 'bold', label: t('designer.weightBold', 'Bold') },
|
||||
]}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={block.fontWeight === 'bold' ? 'filled' : 'default'}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...block,
|
||||
fontWeight: block.fontWeight === 'bold' ? 'normal' : '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
|
||||
label={t('designer.blockColor', 'Colour')}
|
||||
value={block.color ?? '#111111'}
|
||||
onChange={(value) => onChange({ ...block, color: value })}
|
||||
disabled={disabled}
|
||||
format="hex"
|
||||
/>
|
||||
<ColorInput
|
||||
label={t('designer.blockColor', 'Colour')}
|
||||
value={block.color ?? '#111111'}
|
||||
onChange={(value) => onChange({ ...block, color: value })}
|
||||
disabled={disabled}
|
||||
format="hex"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,10 @@ interface Props {
|
||||
onLogoPlacementChange: (placement: TemplateLogoPlacement) => void;
|
||||
landscape: boolean;
|
||||
onLandscapeChange: (landscape: boolean) => void;
|
||||
pageWidth: string;
|
||||
onPageWidthChange: (width: string) => void;
|
||||
pageHeight: string;
|
||||
onPageHeightChange: (height: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -51,6 +55,10 @@ export function TemplateBackgroundPanel({
|
||||
onLogoPlacementChange,
|
||||
landscape,
|
||||
onLandscapeChange,
|
||||
pageWidth,
|
||||
onPageWidthChange,
|
||||
pageHeight,
|
||||
onPageHeightChange,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -91,9 +99,40 @@ export function TemplateBackgroundPanel({
|
||||
]}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{landscape
|
||||
? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
|
||||
: t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
|
||||
{pageWidth && pageHeight
|
||||
? t('designer.customSize', 'Custom size — see below')
|
||||
: landscape
|
||||
? t('designer.a4Landscape', 'A4 — 297 × 210 mm')
|
||||
: t('designer.a4Portrait', 'A4 — 210 × 297 mm')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t('designer.customPageSize', 'Custom page size')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
placeholder={t('designer.pageWidth', 'Width')}
|
||||
value={pageWidth}
|
||||
onChange={(e) => onPageWidthChange(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
w={100}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">×</Text>
|
||||
<TextInput
|
||||
placeholder={t('designer.pageHeight', 'Height')}
|
||||
value={pageHeight}
|
||||
onChange={(e) => onPageHeightChange(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
w={100}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
'designer.customSizeHint',
|
||||
'e.g. 4.92in × 3.46in. Leave both blank to use A4. Overrides orientation\'s A4 size when set.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Paper, Text } from '@mantine/core';
|
||||
import { IconPhoto } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
|
||||
|
||||
@@ -8,6 +9,8 @@ interface Props {
|
||||
logoUrl: string;
|
||||
logoPlacement: TemplateLogoPlacement;
|
||||
landscape: boolean;
|
||||
pageWidth?: string;
|
||||
pageHeight?: string;
|
||||
placements: TemplateFieldPlacement[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
@@ -15,9 +18,17 @@ interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** A4 aspect ratio, the only page size the renderer is configured for. */
|
||||
/** A4 aspect ratio, the fallback for a version with no custom page size. */
|
||||
const A4_RATIO = 297 / 210;
|
||||
|
||||
/** Parses a CSS length like "4.92in" or "125mm" into a unitless number, unit-agnostic — only the ratio between width and height matters here. */
|
||||
function parseLength(value: string): number | null {
|
||||
const match = value.trim().match(/^([\d.]+)/);
|
||||
if (!match) return null;
|
||||
const n = Number(match[1]);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
const LOGO_CORNER_STYLE: Record<string, (offset: number) => React.CSSProperties> = {
|
||||
TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }),
|
||||
TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }),
|
||||
@@ -53,6 +64,8 @@ export function TemplateCanvas({
|
||||
logoUrl,
|
||||
logoPlacement,
|
||||
landscape,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
placements,
|
||||
selectedId,
|
||||
onSelect,
|
||||
@@ -180,6 +193,18 @@ export function TemplateCanvas({
|
||||
logoPlacement.offsetPct ?? 5,
|
||||
);
|
||||
|
||||
// A custom size already states its own orientation (4.92in × 3.46in is
|
||||
// landscape on its own), so it is used as-is rather than flipped again by
|
||||
// `landscape` — that flag only disambiguates the A4 fallback below.
|
||||
const customWidth = pageWidth ? parseLength(pageWidth) : null;
|
||||
const customHeight = pageHeight ? parseLength(pageHeight) : null;
|
||||
const aspectRatio =
|
||||
customWidth && customHeight
|
||||
? customWidth / customHeight
|
||||
: landscape
|
||||
? A4_RATIO
|
||||
: 1 / A4_RATIO;
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
@@ -195,7 +220,7 @@ export function TemplateCanvas({
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO),
|
||||
aspectRatio: String(aspectRatio),
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--mantine-color-gray-4)',
|
||||
overflow: 'hidden',
|
||||
@@ -233,6 +258,7 @@ export function TemplateCanvas({
|
||||
|
||||
{placements.map((block) => {
|
||||
const isSelected = block.id === selectedId;
|
||||
const isImage = block.type === 'image';
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
@@ -247,6 +273,7 @@ export function TemplateCanvas({
|
||||
width: `${block.widthPct}%`,
|
||||
fontSize: block.fontSize ?? 14,
|
||||
fontWeight: block.fontWeight ?? 'normal',
|
||||
fontStyle: block.fontStyle ?? 'normal',
|
||||
textAlign: block.align ?? 'left',
|
||||
color: block.color ?? '#111111',
|
||||
cursor: disabled ? 'default' : 'move',
|
||||
@@ -259,9 +286,30 @@ export function TemplateCanvas({
|
||||
lineHeight: 1.3,
|
||||
wordWrap: 'break-word',
|
||||
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 && (
|
||||
<span
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
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';
|
||||
|
||||
interface Variable {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
import type { TemplateVariable } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
variables: Variable[];
|
||||
variables: TemplateVariable[];
|
||||
disabled: boolean;
|
||||
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
|
||||
canvasMode: boolean;
|
||||
onInsert: (key: string) => void;
|
||||
onAddBlock: (key: string) => void;
|
||||
onAddBlock: (key: string, kind: 'text' | 'image') => void;
|
||||
onAddTextBlock: () => void;
|
||||
}
|
||||
|
||||
@@ -60,8 +56,13 @@ export function TemplateVariableList({
|
||||
variant="default"
|
||||
justify="flex-start"
|
||||
disabled={disabled}
|
||||
leftSection={
|
||||
variable.kind === 'image' ? <IconPhoto size={12} /> : undefined
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -11,8 +11,16 @@ export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
ARCHIVED: 'dark',
|
||||
};
|
||||
|
||||
/** Page options sent with every save and preview — A4, background printed. */
|
||||
export function pageOptionsFor(landscape: boolean) {
|
||||
/**
|
||||
* Page options sent with every save and preview.
|
||||
*
|
||||
* A4 unless the version carries an explicit page size — set for documents
|
||||
* like the Seaman Book, whose ICAO 9303 passport-booklet dimensions have no
|
||||
* named `format` preset. `width`/`height` win over `format` in Puppeteer, so
|
||||
* a custom size is sent alone rather than alongside `format: 'A4'`.
|
||||
*/
|
||||
export function pageOptionsFor(landscape: boolean, size?: { width: string; height: string }) {
|
||||
if (size) return { width: size.width, height: size.height, landscape, printBackground: true };
|
||||
return { format: 'A4' as const, landscape, printBackground: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const x = pct(block.xPct, 0);
|
||||
const y = pct(block.yPct, 0);
|
||||
// Minimum 1%, matching the server compiler: a zero-width block would render
|
||||
// as an invisible sliver rather than as the mistake it is.
|
||||
const width = pct(block.widthPct, 30, 1);
|
||||
|
||||
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 weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
|
||||
const style_ = block.fontStyle === 'italic' ? 'italic' : 'normal';
|
||||
const align = block.align ?? 'left';
|
||||
const color = escapeHtml(block.color ?? '#111111');
|
||||
|
||||
@@ -58,7 +87,7 @@ function blockHtml(block: TemplateFieldPlacement): string {
|
||||
|
||||
const style =
|
||||
`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`;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
const [source, setSource] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [landscape, setLandscape] = useState(true);
|
||||
// Empty string means "use the A4 default" — only a version whose
|
||||
// pageOptions already carries a custom size (e.g. the Seaman Book) starts
|
||||
// with these populated.
|
||||
const [pageWidth, setPageWidth] = useState('');
|
||||
const [pageHeight, setPageHeight] = useState('');
|
||||
const [backgroundUrl, setBackgroundUrl] = useState('');
|
||||
const [logoUrl, setLogoUrl] = useState('');
|
||||
const [logoPlacement, setLogoPlacement] = useState<TemplateLogoPlacement>({});
|
||||
@@ -49,6 +54,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
setSource(selected.hbsSource);
|
||||
setName(selected.name);
|
||||
setLandscape(selected.pageOptions?.landscape ?? true);
|
||||
setPageWidth(selected.pageOptions?.width ?? '');
|
||||
setPageHeight(selected.pageOptions?.height ?? '');
|
||||
setBackgroundUrl(selected.backgroundUrl ?? '');
|
||||
setLogoUrl(selected.logoUrl ?? '');
|
||||
setLogoPlacement(selected.logoPlacement ?? {});
|
||||
@@ -70,6 +77,8 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
(source !== selected?.hbsSource ||
|
||||
name !== selected?.name ||
|
||||
landscape !== (selected?.pageOptions?.landscape ?? true) ||
|
||||
pageWidth !== (selected?.pageOptions?.width ?? '') ||
|
||||
pageHeight !== (selected?.pageOptions?.height ?? '') ||
|
||||
backgroundUrl !== (selected?.backgroundUrl ?? '') ||
|
||||
logoUrl !== (selected?.logoUrl ?? '') ||
|
||||
logoPlacementChanged ||
|
||||
@@ -81,23 +90,46 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
const selectedBlock =
|
||||
placements.find((block) => block.id === selectedBlockId) ?? null;
|
||||
|
||||
/** Drops a new block near the top-left, where it is immediately visible. */
|
||||
const addBlock = useCallback((variable: string | null, text?: string) => {
|
||||
const block: TemplateFieldPlacement = {
|
||||
id: blockId(),
|
||||
variable,
|
||||
text,
|
||||
xPct: 10,
|
||||
yPct: 10,
|
||||
widthPct: 30,
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal',
|
||||
align: 'left',
|
||||
color: '#111111',
|
||||
};
|
||||
setPlacements((prev) => [...prev, block]);
|
||||
setSelectedBlockId(block.id);
|
||||
}, []);
|
||||
/**
|
||||
* Drops a new block near the top-left, where it is immediately visible.
|
||||
*
|
||||
* An image block gets a square-ish default footprint instead of the text
|
||||
* defaults (fontSize/color/align mean nothing on an `<img>`) — a seal or
|
||||
* signature dropped at 30% width and no explicit height would otherwise
|
||||
* stretch to whatever the image's own aspect ratio makes of that width,
|
||||
* which reads as broken until the author manually resizes it.
|
||||
*/
|
||||
const addBlock = useCallback(
|
||||
(variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => {
|
||||
const block: TemplateFieldPlacement =
|
||||
kind === 'image'
|
||||
? {
|
||||
id: blockId(),
|
||||
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) => {
|
||||
setPlacements((prev) =>
|
||||
@@ -137,6 +169,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
setName,
|
||||
landscape,
|
||||
setLandscape,
|
||||
pageWidth,
|
||||
setPageWidth,
|
||||
pageHeight,
|
||||
setPageHeight,
|
||||
backgroundUrl,
|
||||
setBackgroundUrl,
|
||||
logoUrl,
|
||||
|
||||
@@ -9,6 +9,8 @@ interface PreviewArgs {
|
||||
hbsSource: string;
|
||||
licenseTypeId: string | null;
|
||||
landscape: boolean;
|
||||
pageWidth?: string;
|
||||
pageHeight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +23,7 @@ export function useTemplatePreview() {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const open = useCallback(
|
||||
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
|
||||
async ({ hbsSource, licenseTypeId, landscape, pageWidth, pageHeight }: PreviewArgs) => {
|
||||
try {
|
||||
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
|
||||
// and calls the API directly — which means spelling out the base URL and
|
||||
@@ -36,7 +38,10 @@ export function useTemplatePreview() {
|
||||
body: JSON.stringify({
|
||||
hbsSource,
|
||||
licenseTypeId,
|
||||
pageOptions: pageOptionsFor(landscape),
|
||||
pageOptions: pageOptionsFor(
|
||||
landscape,
|
||||
pageWidth && pageHeight ? { width: pageWidth, height: pageHeight } : undefined,
|
||||
),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
|
||||
@@ -221,6 +221,10 @@ export function CertificateDesignerPage() {
|
||||
onLogoPlacementChange={draft.setLogoPlacement}
|
||||
landscape={draft.landscape}
|
||||
onLandscapeChange={draft.setLandscape}
|
||||
pageWidth={draft.pageWidth}
|
||||
onPageWidthChange={draft.setPageWidth}
|
||||
pageHeight={draft.pageHeight}
|
||||
onPageHeightChange={draft.setPageHeight}
|
||||
disabled={editingLocked}
|
||||
/>
|
||||
|
||||
@@ -278,6 +282,8 @@ export function CertificateDesignerPage() {
|
||||
logoUrl={draft.logoUrl}
|
||||
logoPlacement={draft.logoPlacement}
|
||||
landscape={draft.landscape}
|
||||
pageWidth={draft.pageWidth}
|
||||
pageHeight={draft.pageHeight}
|
||||
placements={draft.placements}
|
||||
selectedId={draft.selectedBlockId}
|
||||
onSelect={draft.setSelectedBlockId}
|
||||
@@ -345,6 +351,8 @@ export function CertificateDesignerPage() {
|
||||
: draft.source,
|
||||
licenseTypeId: typeId,
|
||||
landscape: draft.landscape,
|
||||
pageWidth: draft.pageWidth || undefined,
|
||||
pageHeight: draft.pageHeight || undefined,
|
||||
})
|
||||
}
|
||||
onSave={() =>
|
||||
@@ -357,7 +365,12 @@ export function CertificateDesignerPage() {
|
||||
// canvas layout is present, so sending the stale source
|
||||
// alongside it would only fight that.
|
||||
hbsSource: draft.usesCanvas ? undefined : draft.source,
|
||||
pageOptions: pageOptionsFor(draft.landscape),
|
||||
pageOptions: pageOptionsFor(
|
||||
draft.landscape,
|
||||
draft.pageWidth && draft.pageHeight
|
||||
? { width: draft.pageWidth, height: draft.pageHeight }
|
||||
: undefined,
|
||||
),
|
||||
backgroundUrl: draft.backgroundUrl || undefined,
|
||||
logoUrl: draft.logoUrl || undefined,
|
||||
logoPlacement: draft.logoPlacement,
|
||||
@@ -392,7 +405,7 @@ export function CertificateDesignerPage() {
|
||||
disabled={editingLocked}
|
||||
canvasMode={mode === 'canvas'}
|
||||
onInsert={draft.insertVariable}
|
||||
onAddBlock={(key) => draft.addBlock(key)}
|
||||
onAddBlock={(key, kind) => draft.addBlock(key, undefined, kind)}
|
||||
onAddTextBlock={() => draft.addBlock(null, 'Text')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -94,10 +94,16 @@ export function DocumentRequirementEditorDrawer({
|
||||
return;
|
||||
}
|
||||
if (!draft.name.en?.trim()) return;
|
||||
// A condition is either a single-field check or an anyOf list — the CoP
|
||||
// watch_rating_certificate requirement is seeded with anyOf and no field.
|
||||
const hasCondition =
|
||||
Boolean(draft.conditionExpression?.field) ||
|
||||
Boolean(draft.conditionExpression?.anyOf?.length);
|
||||
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
|
||||
if (
|
||||
draft.mode === 'CONDITIONAL' &&
|
||||
!hasCondition &&
|
||||
!draft.conditionExpression?.anyOf?.length
|
||||
) {
|
||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,23 +4,26 @@ import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useGetRanksQuery, useLocalized } from '@ema-platform/api';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
useUpdateCertificationMutation,
|
||||
useDeleteCertificationMutation,
|
||||
} from '../../api/certification-api';
|
||||
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
|
||||
import { type Certification } from '../../types/certification';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationActionsColumn } from './actions';
|
||||
|
||||
function CertificationForm({
|
||||
editing,
|
||||
rankOptions,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
editing: Certification | null;
|
||||
rankOptions: { value: string; label: string }[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
@@ -53,7 +56,7 @@ function CertificationForm({
|
||||
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.')}
|
||||
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
||||
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
|
||||
data={rankOptions}
|
||||
value={rankKey}
|
||||
onChange={setRankKey}
|
||||
size="sm"
|
||||
@@ -74,7 +77,10 @@ export function CertificationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const localized = useLocalized();
|
||||
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 [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
@@ -154,6 +160,7 @@ export function CertificationPage() {
|
||||
{showForm && (
|
||||
<CertificationForm
|
||||
editing={editing}
|
||||
rankOptions={rankOptions}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={resetForm}
|
||||
|
||||
@@ -3,24 +3,6 @@ export interface LocalePair {
|
||||
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 {
|
||||
id: string;
|
||||
name: LocalePair;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
CreateIncidentPayload,
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
GradingSheet,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** The candidate's answers plus auto-computable CHOICE scores, for RecordResultModal. */
|
||||
getGradingSheet: builder.query<
|
||||
GradingSheet,
|
||||
{ examId: string; profileId: string }
|
||||
>({
|
||||
query: ({ examId, profileId }) =>
|
||||
`/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -119,4 +129,5 @@ export const {
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
useRegradeAttemptMutation,
|
||||
useGetGradingSheetQuery,
|
||||
} = examApi;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
Tooltip,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
useUpdateExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
useSelectRandomQuestionsMutation,
|
||||
useGetExamRegistrationsQuery,
|
||||
} from '../api/exam-api';
|
||||
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
@@ -114,6 +116,12 @@ export function ExamDetailPage() {
|
||||
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
|
||||
|
||||
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
|
||||
// The backend locks the paper the moment the first candidate registers
|
||||
// (ExamService.assertPaperEditable) — every candidate must sit the same
|
||||
// paper. Same query ExamCandidatesPanel already runs, so RTK Query serves
|
||||
// it from cache rather than issuing a second request.
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id });
|
||||
const paperLocked = (registrations?.length ?? 0) > 0;
|
||||
const { data: qRes } = useGetQuestionsQuery();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const allQuestions = qRes?.items ?? [];
|
||||
@@ -122,10 +130,14 @@ export function ExamDetailPage() {
|
||||
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
|
||||
// must not offer drafts or retired questions either.
|
||||
//
|
||||
// BOTH describes a mixed paper — a question itself is never "BOTH" (see
|
||||
// Filters on exam.form alone, not administrationMethod: the backend no
|
||||
// longer restricts ONLINE to CHOICE (ExamService no longer has an
|
||||
// assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of
|
||||
// truth for what belongs on the paper, ONLINE or OFFLINE alike. 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).
|
||||
// silently offer zero questions; skipped the same way the backend's own
|
||||
// random draw does (ExamService.selectRandomQuestions).
|
||||
const eligibleQuestions = useMemo(() => {
|
||||
if (!exam) return [];
|
||||
return allQuestions
|
||||
@@ -179,7 +191,9 @@ export function ExamDetailPage() {
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('exam.randomError'));
|
||||
notify.error(
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
key === 'paper_locked_after_registration'
|
||||
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
|
||||
: key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
@@ -200,7 +214,9 @@ export function ExamDetailPage() {
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Failed to assign questions');
|
||||
notify.error(
|
||||
key.startsWith('question_not_approved')
|
||||
key === 'paper_locked_after_registration'
|
||||
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
|
||||
: key.startsWith('question_not_approved')
|
||||
? t('question.qc.onlyApprovedUsable')
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
@@ -452,19 +468,45 @@ export function ExamDetailPage() {
|
||||
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||
</Title>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
{paperLocked && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("exam.paperLocked")}
|
||||
</Badge>
|
||||
)}
|
||||
<Tooltip
|
||||
label={t("exam.paperLockedHint", {
|
||||
count: registrations?.length ?? 0,
|
||||
})}
|
||||
disabled={!paperLocked}
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
{/* Wrapped: a disabled Mantine Button fires no pointer events,
|
||||
so the tooltip needs an enabled element to hang off. */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
disabled={paperLocked}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
{(exam.questions ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t("exam.noQuestionsAssigned")}
|
||||
<Alert
|
||||
color={paperLocked ? "red" : "gray"}
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
{paperLocked
|
||||
? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
|
||||
: t("exam.noQuestionsAssigned")}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||
import { RANK_KEY_OPTIONS } from "../../../certification/types/certification";
|
||||
import { useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
useCreateExamMutation,
|
||||
@@ -79,21 +79,44 @@ function ExamForm({
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
/**
|
||||
* Split per tab so "Next" can check just the tab in front of the user.
|
||||
* Submitting from Basic Info used to complain about Settings fields the
|
||||
* user had not been shown yet — the error was correct and unactionable at
|
||||
* the same time. Each returns the message key for what is missing, or null.
|
||||
*/
|
||||
const validateBasic = (): string | null => {
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.fillRequiredBasic"));
|
||||
return;
|
||||
return "exam.form.fillRequiredBasic";
|
||||
}
|
||||
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.directionBothLanguages"));
|
||||
return "exam.form.directionBothLanguages";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateSettings = (): string | null =>
|
||||
!type || !form || !adminMethod || !evalMethod || !cuttingPoint
|
||||
? "exam.form.fillRequiredSettings"
|
||||
: null;
|
||||
|
||||
const goNext = () => {
|
||||
const error = validateBasic();
|
||||
if (error) {
|
||||
notify.error(t(error));
|
||||
return;
|
||||
}
|
||||
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
||||
setActiveTab("settings");
|
||||
notify.error(t("exam.form.fillRequiredSettings"));
|
||||
setActiveTab("settings");
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Still checks both: the tabs are clickable, so a user can reach Settings
|
||||
// without going through Next.
|
||||
const error = validateBasic() ?? validateSettings();
|
||||
if (error) {
|
||||
setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
|
||||
notify.error(t(error));
|
||||
return;
|
||||
}
|
||||
onSubmit(
|
||||
@@ -255,12 +278,6 @@ function ExamForm({
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
disabled={adminMethod === "ONLINE"}
|
||||
description={
|
||||
adminMethod === "ONLINE"
|
||||
? t("exam.form.onlineChoiceOnlyHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
@@ -270,14 +287,7 @@ function ExamForm({
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={(value) => {
|
||||
setAdminMethod(value);
|
||||
// Online exams are graded automatically, and that only
|
||||
// has an answer model for CHOICE — matches the backend
|
||||
// rule (online_exam_requires_choice_form), not just a
|
||||
// UI nicety.
|
||||
if (value === "ONLINE") setForm("CHOICE");
|
||||
}}
|
||||
onChange={setAdminMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
@@ -350,9 +360,26 @@ function ExamForm({
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
{activeTab === "basic" ? (
|
||||
/* Not type="submit": Basic Info is not the last step, so the
|
||||
primary action advances rather than saves. */
|
||||
<Button size="sm" onClick={goNext}>
|
||||
{t("exam.form.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("basic")}
|
||||
>
|
||||
{t("exam.form.back")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -364,7 +391,10 @@ export function ExamPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const localized = useLocalized();
|
||||
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 { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||
@@ -391,7 +421,7 @@ export function ExamPage() {
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
.map((c) => {
|
||||
const rank = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label;
|
||||
const rank = c.rankKey ? rankLabelByKey.get(c.rankKey) : undefined;
|
||||
return {
|
||||
value: c.id,
|
||||
label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale],
|
||||
|
||||
@@ -133,6 +133,24 @@ export interface ExamRegistration {
|
||||
export type RegradeOutcome =
|
||||
{ graded: true; resultId: string } | { graded: false; reason: string };
|
||||
|
||||
/** One question's row on the staff grading sheet — the candidate's own
|
||||
* answer plus the auto-computable score, where one exists. */
|
||||
export interface GradingSheetQuestion {
|
||||
questionId: string;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
answerText: string | null;
|
||||
selectedOptionId: string | null;
|
||||
selectedOptionText: { en?: string; am?: string } | null;
|
||||
/** null means "no auto-score" — examiner enters one by hand. */
|
||||
autoScore: number | null;
|
||||
}
|
||||
|
||||
export interface GradingSheet {
|
||||
attemptStatus: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
|
||||
questions: GradingSheetQuestion[];
|
||||
}
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, Select, Stack, Text, Textarea } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetAssignableOfficersQuery } from '@ema-platform/api';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { Button } from '@mantine/core';
|
||||
|
||||
export type AssignKind = 'review' | 'inspection';
|
||||
|
||||
interface AssignDialogProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Which stage is being handed out — changes the wording, not the mechanics. */
|
||||
kind: AssignKind;
|
||||
/** Reference of the application being dispatched, shown for confirmation. */
|
||||
applicationNumber?: string;
|
||||
loading?: boolean;
|
||||
onConfirm: (officerId: string, remark?: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The team leader handing work to an employee.
|
||||
*
|
||||
* One dialog for both stages because the decision is identical — pick a person,
|
||||
* optionally say why — and two near-identical modals would drift apart. The
|
||||
* `kind` only selects wording.
|
||||
*
|
||||
* Confirm stays disabled until someone is picked: an assignment with no
|
||||
* assignee is the one mistake this dialog exists to prevent.
|
||||
*/
|
||||
export function AssignDialog({
|
||||
opened,
|
||||
onClose,
|
||||
kind,
|
||||
applicationNumber,
|
||||
loading,
|
||||
onConfirm,
|
||||
}: AssignDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: officers = [], isLoading } = useGetAssignableOfficersQuery();
|
||||
const [officerId, setOfficerId] = useState<string | null>(null);
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
// Reopening for a different application must not offer the previous
|
||||
// dialog's answers as if they had been chosen for this one.
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setOfficerId(null);
|
||||
setRemark('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const title =
|
||||
kind === 'review'
|
||||
? t('queue.assignReview', 'Assign document review')
|
||||
: t('queue.assignInspection', 'Assign inspection');
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} size="md">
|
||||
<Stack gap="md">
|
||||
{applicationNumber && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{applicationNumber}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={
|
||||
kind === 'review'
|
||||
? t('queue.assignReviewTo', 'Employee to review the documents')
|
||||
: t('queue.assignInspectionTo', 'Employee to conduct the inspection')
|
||||
}
|
||||
placeholder={t('queue.selectEmployee', 'Select an employee')}
|
||||
data={officers.map((o) => ({
|
||||
value: o.id,
|
||||
label: o.name ?? o.id,
|
||||
}))}
|
||||
value={officerId}
|
||||
onChange={setOfficerId}
|
||||
disabled={isLoading}
|
||||
searchable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={t('queue.assignRemark', 'Instructions')}
|
||||
description={t(
|
||||
'queue.assignRemarkHint',
|
||||
'Optional. Sent with the assignment notification.',
|
||||
)}
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose} size="sm">
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={loading}
|
||||
disabled={!officerId}
|
||||
onClick={() => officerId && onConfirm(officerId, remark || undefined)}
|
||||
>
|
||||
{t('queue.assignConfirm', 'Assign')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,13 @@ export function DecisionConfirmModal({
|
||||
|
||||
if (!action) return null;
|
||||
|
||||
const needsOfficer = action.id === 'assign' || action.id === 'escalate';
|
||||
// The push-model assignments name a person the same way Assign does, so they
|
||||
// reuse this picker rather than each carrying a dialog of their own.
|
||||
const needsOfficer =
|
||||
action.id === 'assign' ||
|
||||
action.id === 'escalate' ||
|
||||
action.id === 'assign-reviewer' ||
|
||||
action.id === 'assign-inspector';
|
||||
const reasonMissing = action.requiresReason && !reason.trim() && !reasonCode;
|
||||
const blocked =
|
||||
reasonMissing ||
|
||||
@@ -177,7 +183,11 @@ export function DecisionConfirmModal({
|
||||
label={
|
||||
action.id === 'escalate'
|
||||
? t('review.supervisor', 'Supervisor')
|
||||
: t('review.officer', 'Officer')
|
||||
: action.id === 'assign-inspector'
|
||||
? t('review.inspector', 'Inspector')
|
||||
: action.id === 'assign-reviewer'
|
||||
? t('review.reviewer', 'Reviewer')
|
||||
: t('review.officer', 'Officer')
|
||||
}
|
||||
placeholder={t('review.officerPlaceholder', 'Select who takes this on')}
|
||||
data={officers.map((officer) => ({
|
||||
|
||||
@@ -22,6 +22,9 @@ interface Props {
|
||||
* of a per-candidate appointment. Scoped to sittings whose certification
|
||||
* matches this application's rank, so a Chief Mate candidate cannot be seated
|
||||
* into an OOW Deck sitting by accident.
|
||||
*
|
||||
* Scheduling makes the sitting available; it does not register the candidate.
|
||||
* That is their own act, from the portal's Register button.
|
||||
*/
|
||||
export function ScheduleExamModal({
|
||||
opened,
|
||||
@@ -61,7 +64,7 @@ export function ScheduleExamModal({
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('review.scheduleExam.intro', {
|
||||
defaultValue:
|
||||
'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.',
|
||||
'Make a sitting available to {{applicant}}. They register for it themselves from the portal.',
|
||||
applicant: applicantName,
|
||||
})}
|
||||
</Text>
|
||||
@@ -89,7 +92,7 @@ export function ScheduleExamModal({
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'review.scheduleExam.admissionHint',
|
||||
'An admission number is issued automatically when the candidate is seated.',
|
||||
'Scheduling does not seat the candidate. They must register for the sitting from the portal, and the admission number is issued then.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ export type ActionTier =
|
||||
export type ActionId =
|
||||
| 'claim'
|
||||
| 'assign'
|
||||
| 'assign-reviewer'
|
||||
| 'report-review'
|
||||
| 'assign-inspector'
|
||||
| 'report-inspection'
|
||||
| 'escalate'
|
||||
| 'hold'
|
||||
| 'resume'
|
||||
@@ -65,6 +69,56 @@ export interface ActionDefinition {
|
||||
*/
|
||||
export const ACTIONS: ActionDefinition[] = [
|
||||
// ------------------------------------------------------------- workflow
|
||||
{
|
||||
id: 'claim',
|
||||
tier: 'workflow',
|
||||
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',
|
||||
},
|
||||
/**
|
||||
* The push-model actions.
|
||||
*
|
||||
* `assign-reviewer` and `assign-inspector` start a stage; `report-review`
|
||||
* and `report-inspection` hand it back. An employee sees only the two report
|
||||
* actions — the assign pair is gated on ASSIGN_APPLICATION, which their
|
||||
* position type does not carry.
|
||||
*/
|
||||
{
|
||||
id: 'assign-reviewer',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.assignReviewer',
|
||||
from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
|
||||
permissions: ['can:assign:license-application'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
{
|
||||
id: 'report-review',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.reportReview',
|
||||
from: ['UNDER_REVIEW', 'UNDER_EVALUATION'],
|
||||
permissions: ['can:review:license-application'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
{
|
||||
id: 'assign-inspector',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.assignInspector',
|
||||
from: ['REVIEW_REPORTED', 'UNDER_EVALUATION'],
|
||||
permissions: ['can:assign:license-application'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
{
|
||||
id: 'report-inspection',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.reportInspection',
|
||||
from: ['INSPECTION_COMPLETED'],
|
||||
permissions: ['can:update:inspection'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
// Claim is deliberately absent here: an officer claims from the queue
|
||||
// (LicenseQueuePage), not from this detail page. That implementation is
|
||||
// separate — see LicenseQueuePage/actions.tsx — and is unaffected by this.
|
||||
@@ -78,6 +132,7 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'INSPECTION_FAILED',
|
||||
'ON_HOLD',
|
||||
],
|
||||
permissions: ['can:assign:license-application'],
|
||||
@@ -101,6 +156,7 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'INSPECTION_FAILED',
|
||||
],
|
||||
permissions: ['can:hold:license-application'],
|
||||
emphasis: 'subtle',
|
||||
@@ -139,7 +195,8 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
id: 'schedule-inspection',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.scheduleInspection',
|
||||
from: ['INSPECTION_PENDING'],
|
||||
// INSPECTION_FAILED: booking the re-inspection after a failed visit.
|
||||
from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
|
||||
permissions: ['can:create:inspection'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
@@ -147,7 +204,7 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
id: 'record-inspection',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.recordInspection',
|
||||
from: ['INSPECTION_PENDING'],
|
||||
from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
|
||||
permissions: ['can:update:inspection'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
@@ -155,7 +212,15 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
id: 'final-approve',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.finalApprove',
|
||||
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION'],
|
||||
// ELIGIBILITY_PAID: an examined certificate (CoC/CoP) is decided straight
|
||||
// off the eligibility queue — no assignment step.
|
||||
from: [
|
||||
'INSPECTION_COMPLETED',
|
||||
'REVIEW_REPORTED',
|
||||
'INSPECTION_REPORTED',
|
||||
'UNDER_EVALUATION',
|
||||
'ELIGIBILITY_PAID',
|
||||
],
|
||||
permissions: ['can:approve:license-application'],
|
||||
emphasis: 'filled',
|
||||
color: 'teal',
|
||||
@@ -165,7 +230,15 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
id: 'request-adjustment',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.requestAdjustment',
|
||||
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
|
||||
from: [
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_COMPLETED',
|
||||
'REVIEW_REPORTED',
|
||||
'INSPECTION_REPORTED',
|
||||
'INSPECTION_FAILED',
|
||||
'ELIGIBILITY_PAID',
|
||||
],
|
||||
permissions: ['can:request-adjustment:license-application'],
|
||||
emphasis: 'light',
|
||||
color: 'orange',
|
||||
@@ -180,6 +253,10 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'REVIEW_REPORTED',
|
||||
'INSPECTION_REPORTED',
|
||||
'INSPECTION_FAILED',
|
||||
'ELIGIBILITY_PAID',
|
||||
],
|
||||
permissions: ['can:reject:license-application'],
|
||||
emphasis: 'light',
|
||||
@@ -279,11 +356,18 @@ export interface ResolveContext {
|
||||
needsCapital: string;
|
||||
needsInspection: string;
|
||||
needsDocumentReviews: string;
|
||||
inspectionNotYetDue: string;
|
||||
};
|
||||
/** Number of sections/documents the officer has flagged for correction. */
|
||||
flaggedCount: number;
|
||||
/** True when an inspection is scheduled and awaiting a result. */
|
||||
hasPendingInspection: boolean;
|
||||
/**
|
||||
* True while the booked inspection's scheduled instant is still in the
|
||||
* future — a visit cannot have an outcome before it happens, so the record
|
||||
* button waits (the server refuses early results the same way).
|
||||
*/
|
||||
inspectionNotYetDue: boolean;
|
||||
/**
|
||||
* False while any uploaded document is still unjudged or rejected. Approving
|
||||
* is a statement that every document was checked, so the button stays dead
|
||||
@@ -358,12 +442,30 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
|
||||
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
|
||||
|
||||
// The transition table doesn't know which types need an inspection, so
|
||||
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for
|
||||
// types without one — where the server refuses it
|
||||
// (`inspection_not_required_use_final_approve`). Final Approve is the
|
||||
// real action there; don't render its dead twin.
|
||||
if (
|
||||
action.id === 'approve-documents' &&
|
||||
!app.licenseType?.inspectionRequired
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const disabled = (reason: string): ResolvedAction => ({
|
||||
...action,
|
||||
enabled: false,
|
||||
disabledReason: reason,
|
||||
});
|
||||
|
||||
// Booked but not yet due: keep the button visible so the officer sees
|
||||
// the next step, disabled with the scheduled time as the reason.
|
||||
if (action.id === 'record-inspection' && ctx.inspectionNotYetDue) {
|
||||
return disabled(reasons.inspectionNotYetDue);
|
||||
}
|
||||
|
||||
// Decisions belong to whoever holds the application.
|
||||
const needsOwnership =
|
||||
action.tier === 'primary' && action.id !== 'confirm-payment';
|
||||
|
||||
@@ -116,11 +116,21 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
// no capital threshold, no staff roles.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
ENDORSEMENT_SEAFARER: {
|
||||
key: 'ENDORSEMENT_SEAFARER',
|
||||
icon: IconRubberStamp,
|
||||
// Person-centric, same as seafarer registration: no company entity, no
|
||||
// capital threshold, no staff roles, no inspection. Covers CoC and GOC
|
||||
// together — the `endorsementScope` field on the application decides
|
||||
// which certificate section(s) actually have data.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
// Retired by ENDORSEMENT_SEAFARER (see endorsements.seed-data.ts). Kept so
|
||||
// an application filed before the switch still renders with the right
|
||||
// presentation instead of falling back to the generic company layout.
|
||||
ENDORSEMENT_COC: {
|
||||
key: 'ENDORSEMENT_COC',
|
||||
icon: IconRubberStamp,
|
||||
// Person-centric, same as seafarer registration: no company entity, no
|
||||
// capital threshold, no staff roles, no inspection.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
ENDORSEMENT_GOC: {
|
||||
|
||||
@@ -7,11 +7,12 @@ import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
export function licenseQueueActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
claiming: boolean;
|
||||
onClaim: (id: string) => void;
|
||||
assigning: boolean;
|
||||
/** Opens the assign dialog. Team leaders only — see below. */
|
||||
onAssign: (application: LicenseApplication) => void;
|
||||
onOpen: (id: string) => void;
|
||||
/** False for a non-logistics queue — there's no unclaimed pool to claim from. */
|
||||
claimable?: boolean;
|
||||
/** False for a non-logistics queue — nothing there is dispatched. */
|
||||
assignable?: boolean;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
return {
|
||||
@@ -19,24 +20,32 @@ export function licenseQueueActionsColumn(
|
||||
label: t("queue.actionsColumn", "Actions"),
|
||||
align: "right",
|
||||
size: 140,
|
||||
/**
|
||||
* "Assign", not "Claim".
|
||||
*
|
||||
* Work is pushed, not picked up: an unassigned application is waiting for
|
||||
* the team leader to hand it to someone, and no seeded position type holds
|
||||
* `CLAIM_APPLICATION` any more. Guarded on `ASSIGN_APPLICATION`, so an
|
||||
* employee sees only "Review" on the files that are already theirs.
|
||||
*/
|
||||
cell: ({ row }) =>
|
||||
handlers.claimable !== false &&
|
||||
handlers.assignable !== false &&
|
||||
row.original.assignedOfficerId === null &&
|
||||
// Mirrors the CLAIM transition's `from` list: an examined cert
|
||||
// Mirrors the ASSIGN_REVIEWER transition's `from` list: an examined cert
|
||||
// (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears,
|
||||
// not SUBMITTED.
|
||||
(row.original.status === "SUBMITTED" ||
|
||||
row.original.status === "ELIGIBILITY_PAID") ? (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
anyOf={[LICENSE_PERMISSIONS.ASSIGN_APPLICATION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
loading={handlers.claiming}
|
||||
onClick={() => handlers.onClaim(row.original.id)}
|
||||
loading={handlers.assigning}
|
||||
onClick={() => handlers.onAssign(row.original)}
|
||||
>
|
||||
{t("queue.claim", "Claim")}
|
||||
{t("queue.assign", "Assign")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
) : (
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
familyLabels,
|
||||
localized,
|
||||
resolveFamilyKind,
|
||||
useClaimApplicationMutation,
|
||||
useAssignReviewerMutation,
|
||||
useGetAllApplicationsQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
@@ -54,12 +54,12 @@ import {
|
||||
AmharicDatePicker,
|
||||
type AdvancedColumn,
|
||||
} from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
import {
|
||||
DEFAULT_VIEW,
|
||||
SAVED_VIEWS,
|
||||
filterFromSearchParams,
|
||||
readLastView,
|
||||
hasUnclaimedPool,
|
||||
savedViewsForFamily,
|
||||
searchParamsFromFilter,
|
||||
writeLastView,
|
||||
@@ -70,6 +70,7 @@ import { setDensity } from "../../../../store/preferences.slice";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
||||
import { licenseQueueColumns } from "./columns";
|
||||
import { AssignDialog } from "../../components/AssignDialog";
|
||||
import { licenseQueueActionsColumn } from "./actions";
|
||||
import { PageHeader } from '@ema-platform/ui';
|
||||
|
||||
@@ -159,12 +160,16 @@ export function LicenseQueuePage() {
|
||||
const isLogistics = typeCode
|
||||
? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE"
|
||||
: undefined;
|
||||
const visibleViews = savedViewsForFamily(isLogistics !== false);
|
||||
// Claiming is a workflow property, not a label one — Vessel Registration is
|
||||
// a DOCUMENT family but is still triaged off a shared unclaimed pool, so it
|
||||
// keeps the Unassigned tab and the claim actions.
|
||||
const claimable = hasUnclaimedPool(typeCode);
|
||||
const visibleViews = savedViewsForFamily(claimable);
|
||||
|
||||
const [view, setView] = useState<SavedViewId>(
|
||||
() =>
|
||||
(searchParams.get("view") as SavedViewId) ||
|
||||
(isLogistics === false ? "all" : readLastView()),
|
||||
(claimable ? readLastView() : "all"),
|
||||
);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
@@ -174,21 +179,17 @@ export function LicenseQueuePage() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
// Non-logistics queues have no unassigned/unclaimed pool (see
|
||||
// `savedViewsForFamily`), so a stale "unassigned" view — e.g. restored from
|
||||
// Queues with no unclaimed pool (see `hasUnclaimedPool`) have no unassigned
|
||||
// tab, so a stale "unassigned" view — e.g. restored from
|
||||
// `readLastView()` — must fall back to "all" rather than land on a tab that
|
||||
// no longer exists. Auto-created BTC requests specifically start at
|
||||
// PAYMENT_PENDING, outside "mine" too, so "all" is the one view guaranteed
|
||||
// to show them.
|
||||
useEffect(() => {
|
||||
if (
|
||||
isLogistics === false &&
|
||||
!searchParams.has("view") &&
|
||||
view === "unassigned"
|
||||
) {
|
||||
if (!claimable && !searchParams.has("view") && view === "unassigned") {
|
||||
setView("all");
|
||||
}
|
||||
}, [isLogistics, searchParams, view]);
|
||||
}, [claimable, searchParams, view]);
|
||||
|
||||
const urlFilter = useMemo(
|
||||
() => filterFromSearchParams(searchParams),
|
||||
@@ -267,7 +268,8 @@ export function LicenseQueuePage() {
|
||||
? mineQuery
|
||||
: allQuery;
|
||||
|
||||
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
|
||||
const [assignReviewer, { isLoading: assigning }] = useAssignReviewerMutation();
|
||||
const [assignTarget, setAssignTarget] = useState<LicenseApplication | null>(null);
|
||||
const [runExport, { isFetching: exporting }] =
|
||||
useLazyExportApplicationsQuery();
|
||||
|
||||
@@ -354,58 +356,40 @@ export function LicenseQueuePage() {
|
||||
setFacet({ sortBy: field, sortDir: dir });
|
||||
};
|
||||
|
||||
async function handleClaim(id: string) {
|
||||
/**
|
||||
* The team leader dispatching one application.
|
||||
*
|
||||
* Replaces the old self-service claim: nothing is picked up any more, so the
|
||||
* queue's primary action is handing a file to an employee. The dialog holds
|
||||
* the choice; this only sends it.
|
||||
*/
|
||||
async function handleAssign(officerId: string, remark?: string) {
|
||||
if (!assignTarget) return;
|
||||
try {
|
||||
await claim(id).unwrap();
|
||||
await assignReviewer({ id: assignTarget.id, officerId, remark }).unwrap();
|
||||
notifications.show({
|
||||
color: "teal",
|
||||
title: t("queue.claimed", "Claimed"),
|
||||
title: t("queue.assigned", "Assigned"),
|
||||
message: t(
|
||||
"queue.claimedBody",
|
||||
"The application is now assigned to you.",
|
||||
"queue.assignedBody",
|
||||
"The employee has been notified and the review has started.",
|
||||
),
|
||||
});
|
||||
changeView("mine");
|
||||
setAssignTarget(null);
|
||||
active.refetch();
|
||||
} catch (err) {
|
||||
// A 409 means another officer got there first — refresh so the queue
|
||||
// stops showing work that is no longer available.
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: t("queue.claimFailed", "Could not claim"),
|
||||
title: t("queue.assignFailed", "Could not assign"),
|
||||
message: extractErrorMessage(
|
||||
err,
|
||||
t("queue.claimRace", "Another officer already claimed it."),
|
||||
t("queue.assignError", "The application could not be assigned."),
|
||||
),
|
||||
});
|
||||
active.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkClaim() {
|
||||
const results = await Promise.allSettled(
|
||||
selected.map((id) => claim(id).unwrap()),
|
||||
);
|
||||
const claimed = results.filter((r) => r.status === "fulfilled").length;
|
||||
const lost = results.length - claimed;
|
||||
notifications.show({
|
||||
color: lost ? "yellow" : "teal",
|
||||
title: t("queue.bulkClaimed", {
|
||||
count: claimed,
|
||||
defaultValue: "{{count}} claimed",
|
||||
}),
|
||||
// Partial success is the normal case in a shared queue, so it is
|
||||
// reported rather than swallowed or treated as total failure.
|
||||
message: lost
|
||||
? t("queue.bulkClaimPartial", {
|
||||
count: lost,
|
||||
defaultValue: "{{count}} were already taken by another officer.",
|
||||
})
|
||||
: "",
|
||||
});
|
||||
setSelected([]);
|
||||
active.refetch();
|
||||
}
|
||||
|
||||
const cursorRow = items[cursor];
|
||||
useQueueKeyboard({
|
||||
enabled: !helpOpen,
|
||||
@@ -414,9 +398,15 @@ export function LicenseQueuePage() {
|
||||
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
||||
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
||||
onClaim: () => {
|
||||
// Only unclaimed rows on a logistics queue can be claimed; pressing c
|
||||
// elsewhere is a no-op rather than an error the officer has to read.
|
||||
// "c" now opens the assign dialog on an undispatched row. Kept on the
|
||||
// same key: it is still "do the queue's primary action to this row",
|
||||
// and rebinding a shortcut officers have in their fingers costs more
|
||||
// than the name mismatch.
|
||||
if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
|
||||
setAssignTarget(cursorRow);
|
||||
// Only unclaimed rows on a claimable queue can be claimed; pressing c
|
||||
// elsewhere is a no-op rather than an error the officer has to read.
|
||||
if (claimable && cursorRow && cursorRow.assignedOfficerId === null)
|
||||
handleClaim(cursorRow.id);
|
||||
},
|
||||
onEscape: () => setSelected([]),
|
||||
@@ -478,12 +468,15 @@ export function LicenseQueuePage() {
|
||||
isLogistics,
|
||||
}),
|
||||
licenseQueueActionsColumn(t, {
|
||||
claiming,
|
||||
onClaim: handleClaim,
|
||||
assigning,
|
||||
onAssign: setAssignTarget,
|
||||
onOpen: (id) => navigate(`/licence-review/${id}`),
|
||||
// Non-logistics applications aren't claimed off a shared queue (see
|
||||
// Non-logistics applications aren't dispatched off a shared queue (see
|
||||
// `savedViewsForFamily`) — every row opens straight to Review.
|
||||
claimable: isLogistics !== false,
|
||||
assignable: isLogistics !== false,
|
||||
// Applications with no unclaimed pool (see `hasUnclaimedPool`) are
|
||||
// never claimed — every row opens straight to Review.
|
||||
claimable,
|
||||
}),
|
||||
],
|
||||
[
|
||||
@@ -494,8 +487,9 @@ export function LicenseQueuePage() {
|
||||
selected,
|
||||
allSelected,
|
||||
items,
|
||||
claiming,
|
||||
assigning,
|
||||
isLogistics,
|
||||
claimable,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -763,7 +757,7 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
{isLogistics !== false && (
|
||||
{claimable && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
@@ -780,6 +774,14 @@ export function LicenseQueuePage() {
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
<AssignDialog
|
||||
opened={assignTarget !== null}
|
||||
onClose={() => setAssignTarget(null)}
|
||||
kind="review"
|
||||
applicationNumber={assignTarget?.applicationNumber}
|
||||
loading={assigning}
|
||||
onConfirm={handleAssign}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
FileButton,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
@@ -24,8 +27,11 @@ import {
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconFileDownload,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconPaperclip,
|
||||
IconQuestionMark,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -39,6 +45,10 @@ import {
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useAssignReviewerMutation,
|
||||
useAssignInspectorMutation,
|
||||
useReportReviewMutation,
|
||||
useReportInspectionMutation,
|
||||
useClaimApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
@@ -60,6 +70,8 @@ import {
|
||||
useRequestAdjustmentMutation,
|
||||
useResumeApplicationMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useGetCertificateUrlForOfficerMutation,
|
||||
uploadDocument,
|
||||
type RemarkTargetType,
|
||||
type StaffEvidenceRequirement,
|
||||
} from "@ema-platform/api";
|
||||
@@ -210,6 +222,11 @@ export function LicenseReviewPage() {
|
||||
const [confirmPayment] = useConfirmPaymentMutation();
|
||||
const [scheduleIssuance] = useScheduleIssuanceMutation();
|
||||
const [issueCertificate] = useIssueCertificateMutation();
|
||||
const [getCertificateUrlForOfficer] = useGetCertificateUrlForOfficerMutation();
|
||||
const [certificateBusy, setCertificateBusy] = useState<"view" | "download" | null>(
|
||||
null,
|
||||
);
|
||||
const [certificatePreview, setCertificatePreview] = useState<string | null>(null);
|
||||
const [scheduleExam, { isLoading: schedulingExam }] =
|
||||
useScheduleExamMutation();
|
||||
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
||||
@@ -217,6 +234,13 @@ export function LicenseReviewPage() {
|
||||
const [resumeApplication] = useResumeApplicationMutation();
|
||||
const [escalateApplication] = useEscalateApplicationMutation();
|
||||
const [assignApplication] = useAssignApplicationMutation();
|
||||
const [assignReviewer, { isLoading: assigningReviewer }] =
|
||||
useAssignReviewerMutation();
|
||||
const [assignInspector, { isLoading: assigningInspector }] =
|
||||
useAssignInspectorMutation();
|
||||
const [reportReview] = useReportReviewMutation();
|
||||
const [reportInspection] = useReportInspectionMutation();
|
||||
|
||||
// Real officer list, so Assign and Escalate name a person instead of
|
||||
// silently reassigning to whoever already held the application.
|
||||
const { data: officers = [] } = useGetAssignableOfficersQuery();
|
||||
@@ -234,6 +258,9 @@ export function LicenseReviewPage() {
|
||||
const [railOpen, setRailOpen] = useState(true);
|
||||
const [inspectionOpen, setInspectionOpen] = useState(false);
|
||||
const [inspectionDate, setInspectionDate] = useState("");
|
||||
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
|
||||
"MORNING",
|
||||
);
|
||||
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
||||
const [issuanceDate, setIssuanceDate] = useState("");
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
@@ -241,6 +268,10 @@ export function LicenseReviewPage() {
|
||||
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||
const [examScore, setExamScore] = useState<number | undefined>();
|
||||
const [findings, setFindings] = useState("");
|
||||
const [findingsUploadBusy, setFindingsUploadBusy] = useState(false);
|
||||
const [findingsPreview, setFindingsPreview] = useState<
|
||||
{ url: string; title: string } | null
|
||||
>(null);
|
||||
const [checklist, setChecklist] = useState<
|
||||
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
||||
>({});
|
||||
@@ -279,6 +310,18 @@ export function LicenseReviewPage() {
|
||||
}, [flags]);
|
||||
|
||||
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
||||
// A visit cannot have an outcome before it happens — mirror of the server's
|
||||
// inspection_not_yet_due guard, compared instant-to-instant.
|
||||
const inspectionNotYetDue = Boolean(
|
||||
pendingInspection?.scheduledDate &&
|
||||
new Date(pendingInspection.scheduledDate) > new Date(),
|
||||
);
|
||||
|
||||
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
|
||||
useGetAttachmentsQuery(
|
||||
{ ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
|
||||
{ skip: !pendingInspection },
|
||||
);
|
||||
|
||||
// Approving means every uploaded document was accepted — one unjudged or
|
||||
// rejected file is enough to keep the decision buttons dead. The counts feed
|
||||
@@ -335,6 +378,7 @@ export function LicenseReviewPage() {
|
||||
can,
|
||||
flaggedCount: flagged.length,
|
||||
hasPendingInspection: Boolean(pendingInspection),
|
||||
inspectionNotYetDue,
|
||||
allDocumentsAccepted,
|
||||
reasons: {
|
||||
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
||||
@@ -343,6 +387,13 @@ export function LicenseReviewPage() {
|
||||
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
||||
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
||||
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
||||
inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', {
|
||||
date: pendingInspection?.scheduledDate
|
||||
? showDate(pendingInspection.scheduledDate)
|
||||
: '',
|
||||
defaultValue:
|
||||
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
|
||||
}),
|
||||
needsDocumentReviews:
|
||||
documentProgress.total === 0
|
||||
? t(
|
||||
@@ -357,7 +408,7 @@ export function LicenseReviewPage() {
|
||||
}),
|
||||
},
|
||||
});
|
||||
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
|
||||
}, [data, currentUserId, can, flagged.length, pendingInspection, inspectionNotYetDue, showDate, t]);
|
||||
|
||||
// Location answers are tree ids. The picker the applicant used resolves them
|
||||
// client-side from the same list, so the reviewer reads the place rather than
|
||||
@@ -479,6 +530,34 @@ export function LicenseReviewPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a fresh presigned link and either opens the in-app PDF preview
|
||||
* or a new tab, depending which button was pressed — the link itself is
|
||||
* short-lived, so each click gets its own rather than caching one.
|
||||
*/
|
||||
async function openCertificate(mode: "view" | "download") {
|
||||
if (!app.issuedLicenseId) return;
|
||||
setCertificateBusy(mode);
|
||||
try {
|
||||
const { url } = await getCertificateUrlForOfficer(
|
||||
app.issuedLicenseId,
|
||||
).unwrap();
|
||||
if (mode === "view") {
|
||||
setCertificatePreview(url);
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: t("review.certificateError", "Could not open the certificate"),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
} finally {
|
||||
setCertificateBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions with their own dedicated form open that; the rest confirm. */
|
||||
function handleAction(action: ResolvedAction) {
|
||||
switch (action.id) {
|
||||
@@ -533,11 +612,51 @@ export function LicenseReviewPage() {
|
||||
try {
|
||||
switch (action.id) {
|
||||
case "claim":
|
||||
// Usually fired from the queue, but an officer can also open an
|
||||
// unclaimed application directly and claim it from here.
|
||||
// Usually fired from the queue, but an officer who opened an
|
||||
// unclaimed application directly claims it from here.
|
||||
await run(
|
||||
() => claimApplication(id).unwrap(),
|
||||
t("review.done.claim", "Application claimed"),
|
||||
t("review.done.claim", "Claimed — the application is now yours"),
|
||||
);
|
||||
break;
|
||||
// Handing work out. Reuses the decision modal's officer picker rather
|
||||
// than a dialog of its own — it already resolves the real officer list.
|
||||
case "assign-reviewer":
|
||||
if (!submission.officerId) return;
|
||||
await run(
|
||||
() =>
|
||||
assignReviewer({
|
||||
id,
|
||||
officerId: submission.officerId as string,
|
||||
remark: submission.reason,
|
||||
}).unwrap(),
|
||||
t("review.done.assignReviewer", "Review assigned"),
|
||||
);
|
||||
break;
|
||||
case "assign-inspector":
|
||||
if (!submission.officerId) return;
|
||||
await run(
|
||||
() =>
|
||||
assignInspector({
|
||||
id,
|
||||
inspectorId: submission.officerId as string,
|
||||
remark: submission.reason,
|
||||
}).unwrap(),
|
||||
t("review.done.assignInspector", "Inspection assigned"),
|
||||
);
|
||||
break;
|
||||
// Handing work back. Parks the file with the team leader — an employee
|
||||
// finishing their task decides nothing.
|
||||
case "report-review":
|
||||
await run(
|
||||
() => reportReview({ id, remark: submission.reason }).unwrap(),
|
||||
t("review.done.reportReview", "Sent to your team leader"),
|
||||
);
|
||||
break;
|
||||
case "report-inspection":
|
||||
await run(
|
||||
() => reportInspection({ id, remark: submission.reason }).unwrap(),
|
||||
t("review.done.reportInspection", "Inspection result sent"),
|
||||
);
|
||||
break;
|
||||
case "complete-review":
|
||||
@@ -830,6 +949,38 @@ export function LicenseReviewPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Only once the certificate actually exists — before that there
|
||||
is nothing to view or download yet. */}
|
||||
{app.issuedLicenseId && (
|
||||
<Paper withBorder p="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t("review.certificate", "Certificate")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEye size={14} />}
|
||||
loading={certificateBusy === "view"}
|
||||
disabled={Boolean(certificateBusy)}
|
||||
onClick={() => openCertificate("view")}
|
||||
>
|
||||
{t("review.view", "View")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconFileDownload size={14} />}
|
||||
loading={certificateBusy === "download"}
|
||||
disabled={Boolean(certificateBusy)}
|
||||
onClick={() => openCertificate("download")}
|
||||
>
|
||||
{t("review.download", "Download")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Eligibility, checked and shown — not applied invisibly. */}
|
||||
{eligibility.length > 0 && (
|
||||
<Paper withBorder p="md">
|
||||
@@ -1063,6 +1214,18 @@ export function LicenseReviewPage() {
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="inspection">
|
||||
{status === "INSPECTION_FAILED" && (
|
||||
<Alert
|
||||
mb="md"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
>
|
||||
{t(
|
||||
"review.inspectionFailedBlocked",
|
||||
"Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
<Paper withBorder p="md">
|
||||
{inspections.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -1078,7 +1241,15 @@ export function LicenseReviewPage() {
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{inspection.scheduledDate
|
||||
? showDate(inspection.scheduledDate)
|
||||
? `${showDate(inspection.scheduledDate)}${
|
||||
inspection.timeSlot
|
||||
? ` — ${
|
||||
inspection.timeSlot === "MORNING"
|
||||
? t("review.morning", "Morning")
|
||||
: t("review.afternoon", "Afternoon")
|
||||
}`
|
||||
: ""
|
||||
}`
|
||||
: t("review.unscheduled", "Not scheduled")}
|
||||
</Text>
|
||||
{inspection.findings && (
|
||||
@@ -1263,14 +1434,21 @@ export function LicenseReviewPage() {
|
||||
>
|
||||
<Stack>
|
||||
<AmharicDatePicker
|
||||
label={t("review.dateTime", "Date and time")}
|
||||
label={t("review.date", "Date")}
|
||||
value={inspectionDate}
|
||||
onChange={setInspectionDate}
|
||||
withTime
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={inspectionTimeSlot}
|
||||
onChange={(value) => setInspectionTimeSlot(value as "MORNING" | "AFTERNOON")}
|
||||
data={[
|
||||
{ value: "MORNING", label: t("review.morning", "Morning") },
|
||||
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
|
||||
]}
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Tooltip
|
||||
label={t("review.pickDate", "Pick a date and time first")}
|
||||
label={t("review.pickDate", "Pick a date first")}
|
||||
disabled={Boolean(inspectionDate)}
|
||||
>
|
||||
<span>
|
||||
@@ -1288,6 +1466,7 @@ export function LicenseReviewPage() {
|
||||
await scheduleInspection({
|
||||
applicationId: id,
|
||||
scheduledDate: inspectionDate,
|
||||
timeSlot: inspectionTimeSlot,
|
||||
}).unwrap();
|
||||
setInspectionOpen(false);
|
||||
},
|
||||
@@ -1386,12 +1565,77 @@ export function LicenseReviewPage() {
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("review.findingsEvidence", "Supporting documents")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{findingsEvidence.flatMap((attachment) =>
|
||||
(attachment.files ?? []).map((file) => (
|
||||
<ActionIcon
|
||||
key={file.id}
|
||||
variant="light"
|
||||
size="lg"
|
||||
aria-label={file.originalName}
|
||||
onClick={() =>
|
||||
file.url &&
|
||||
setFindingsPreview({ url: file.url, title: file.originalName })
|
||||
}
|
||||
>
|
||||
<IconEye size={16} />
|
||||
</ActionIcon>
|
||||
)),
|
||||
)}
|
||||
<FileButton
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
onChange={async (file) => {
|
||||
if (!file || !pendingInspection) return;
|
||||
setFindingsUploadBusy(true);
|
||||
await uploadDocument({
|
||||
ownerType: "INSPECTION",
|
||||
ownerId: pendingInspection.id,
|
||||
documentKey: `evidence-${Date.now()}`,
|
||||
file,
|
||||
});
|
||||
setFindingsUploadBusy(false);
|
||||
refetchFindingsEvidence();
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<ActionIcon
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={!pendingInspection || findingsUploadBusy}
|
||||
aria-label={t("review.uploadEvidence", "Upload document")}
|
||||
>
|
||||
{findingsUploadBusy ? (
|
||||
<Loader size={14} type="oval" />
|
||||
) : (
|
||||
<IconPaperclip size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
{inspectionNotYetDue && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("review.disabled.inspectionNotYetDue", {
|
||||
date: pendingInspection?.scheduledDate
|
||||
? showDate(pendingInspection.scheduledDate)
|
||||
: "",
|
||||
defaultValue:
|
||||
"Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.",
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
<ModalFooter grow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="lg"
|
||||
disabled={!findings.trim() || !pendingInspection}
|
||||
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
|
||||
aria-label={t("review.passed", "Passed")}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -1418,7 +1662,7 @@ export function LicenseReviewPage() {
|
||||
variant="light"
|
||||
color="red"
|
||||
size="lg"
|
||||
disabled={!findings.trim() || !pendingInspection}
|
||||
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
|
||||
aria-label={t("review.failed", "Failed")}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -1444,6 +1688,20 @@ export function LicenseReviewPage() {
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(findingsPreview)}
|
||||
onClose={() => setFindingsPreview(null)}
|
||||
url={findingsPreview?.url ?? ""}
|
||||
title={findingsPreview?.title}
|
||||
/>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(certificatePreview)}
|
||||
onClose={() => setCertificatePreview(null)}
|
||||
url={certificatePreview ?? ""}
|
||||
title={t("review.certificate", "Certificate")}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFamilyKind } from "@ema-platform/api";
|
||||
import { hasUnclaimedPool, savedViewsForFamily } from "./queue-views";
|
||||
|
||||
describe("resolveFamilyKind", () => {
|
||||
it("treats person-centric seafarer applications as document queues", () => {
|
||||
@@ -8,3 +9,28 @@ describe("resolveFamilyKind", () => {
|
||||
expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUnclaimedPool", () => {
|
||||
it("keeps the unassigned pool for logistics licences", () => {
|
||||
expect(hasUnclaimedPool("FREIGHT_FORWARDER")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps it for vessel registration, a DOCUMENT queue that is still claimed", () => {
|
||||
expect(resolveFamilyKind("VESSEL_REGISTRATION")).toBe("DOCUMENT");
|
||||
expect(hasUnclaimedPool("VESSEL_REGISTRATION")).toBe(true);
|
||||
expect(savedViewsForFamily(hasUnclaimedPool("VESSEL_REGISTRATION")).map((v) => v.id)).toContain(
|
||||
"unassigned",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops it for the person-centric services", () => {
|
||||
expect(hasUnclaimedPool("SEAMAN_BOOK")).toBe(false);
|
||||
expect(savedViewsForFamily(hasUnclaimedPool("SEAMAN_BOOK")).map((v) => v.id)).not.toContain(
|
||||
"unassigned",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps it for the mixed All/Mine grids, where no type is pinned", () => {
|
||||
expect(hasUnclaimedPool(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveFamilyKind } from '@ema-platform/api';
|
||||
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
|
||||
|
||||
export type SavedViewId =
|
||||
@@ -78,12 +79,39 @@ export const SAVED_VIEWS: SavedView[] = [
|
||||
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
|
||||
|
||||
/**
|
||||
* Non-logistics queues (Seafarer Registration, Seaman Book, BTC, CoC, ...)
|
||||
* have no unclaimed pool to triage — those applications aren't claimed off a
|
||||
* shared queue — so the tab that lists it doesn't apply there.
|
||||
* Type keys reviewed off a shared unclaimed pool despite not being logistics
|
||||
* licences. Whether a queue is claimed is an officer-workflow property, not a
|
||||
* label one: vessel registrations arrive unassigned and officers claim them,
|
||||
* even though the family kind is DOCUMENT because the certificate they produce
|
||||
* is a document rather than a licence.
|
||||
*/
|
||||
export function savedViewsForFamily(isLogistics: boolean): SavedView[] {
|
||||
return isLogistics
|
||||
const CLAIMABLE_NON_LOGISTICS = new Set([
|
||||
'VESSEL_REGISTRATION',
|
||||
// Endorsements run the standard claim-first workflow: applications arrive
|
||||
// unassigned and an officer claims them, even though the family kind is
|
||||
// CERTIFICATE.
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Does this queue have an unclaimed pool to triage?
|
||||
*
|
||||
* True for the mixed All/Mine grids (no type pinned) — nothing is hidden when
|
||||
* the queue spans every type. False for the person-centric services (Seafarer
|
||||
* Registration, Seaman Book, BTC, CoC, ...), whose applications aren't claimed
|
||||
* off a shared queue, so the tab and the claim actions don't apply there.
|
||||
*/
|
||||
export function hasUnclaimedPool(typeCode: string | undefined): boolean {
|
||||
if (!typeCode) return true;
|
||||
return (
|
||||
resolveFamilyKind(typeCode) === 'LOGISTICS_LICENSE' ||
|
||||
CLAIMABLE_NON_LOGISTICS.has(typeCode)
|
||||
);
|
||||
}
|
||||
|
||||
/** Drops the Unassigned tab on queues with no unclaimed pool. */
|
||||
export function savedViewsForFamily(claimable: boolean): SavedView[] {
|
||||
return claimable
|
||||
? SAVED_VIEWS
|
||||
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NumberInput, Text, TextInput } from '@mantine/core';
|
||||
import { Badge, Group, NumberInput, Text, TextInput } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { QuestionBrief } from '../../../exam/types/exam';
|
||||
import type { GradingSheetQuestion, QuestionBrief } from '../../../exam/types/exam';
|
||||
|
||||
export function recordResultColumns(
|
||||
t: TFunction,
|
||||
@@ -11,6 +11,9 @@ export function recordResultColumns(
|
||||
questionRemarks: Record<string, string>;
|
||||
onScoreChange: (questionId: string, value: number) => void;
|
||||
onRemarkChange: (questionId: string, value: string) => void;
|
||||
/** The candidate's own answer + auto-score, when available (empty for
|
||||
* an OFFLINE candidate or one who hasn't sat an online attempt). */
|
||||
answersByQuestion: Map<string, GradingSheetQuestion>;
|
||||
},
|
||||
): AdvancedColumn<QuestionBrief>[] {
|
||||
return [
|
||||
@@ -22,6 +25,20 @@ export function recordResultColumns(
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.recordModal.candidateAnswer'),
|
||||
cell: ({ row }) => {
|
||||
const answer = handlers.answersByQuestion.get(row.original.id);
|
||||
if (!answer || (!answer.answerText && !answer.selectedOptionText)) {
|
||||
return <Text fz="xs" c="dimmed">{t('result.recordModal.noAnswer')}</Text>;
|
||||
}
|
||||
return (
|
||||
<Text fz="sm" maw={220} lineClamp={3}>
|
||||
{answer.selectedOptionText?.[locale] ?? answer.answerText}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('result.recordModal.maxPoints'),
|
||||
cell: ({ row }) => (
|
||||
@@ -32,16 +49,26 @@ export function recordResultColumns(
|
||||
},
|
||||
{
|
||||
header: t('result.recordModal.score'),
|
||||
cell: ({ row }) => (
|
||||
<NumberInput
|
||||
value={handlers.scores[row.original.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
|
||||
min={0}
|
||||
max={row.original.points}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const autoGraded = handlers.answersByQuestion.get(row.original.id)?.autoScore != null;
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<NumberInput
|
||||
value={handlers.scores[row.original.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
|
||||
min={0}
|
||||
max={row.original.points}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
{autoGraded && (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
{t('result.recordModal.autoGraded')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('result.recordModal.remark'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
@@ -20,7 +20,7 @@ import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { recordResultColumns } from './columns';
|
||||
import { useCreateResultMutation } from '../../api/result-api';
|
||||
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
|
||||
import { useGetExamRegistrationsQuery, useGetGradingSheetQuery } from '../../../exam/api/exam-api';
|
||||
import type { Exam } from '../../../exam/types/exam';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
@@ -55,12 +55,39 @@ export function RecordResultModal({
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(exam.id, {
|
||||
skip: !opened,
|
||||
});
|
||||
// The candidate's own answers plus whatever score auto-grading could
|
||||
// already compute for the CHOICE portion — degrades to "no data" for an
|
||||
// OFFLINE candidate or one who never sat an online attempt, same as
|
||||
// before this existed.
|
||||
const { data: gradingSheet } = useGetGradingSheetQuery(
|
||||
{ examId: exam.id, profileId: selectedSeafarerId ?? '' },
|
||||
{ skip: !opened || !selectedSeafarerId },
|
||||
);
|
||||
const answersByQuestion = new Map(
|
||||
(gradingSheet?.questions ?? []).map((q) => [q.questionId, q]),
|
||||
);
|
||||
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
|
||||
const table = useServerTable();
|
||||
|
||||
const questions = exam.questions ?? [];
|
||||
const pagedQuestions = table.paginate(questions);
|
||||
|
||||
// Prefill (never override) the CHOICE questions auto-grading already
|
||||
// scored — the examiner only has to key in the ESSAY marks. A fresh
|
||||
// seafarer selection always starts from an empty scores map, so this
|
||||
// only ever fills in blanks, never stomps a manual edit already made.
|
||||
useEffect(() => {
|
||||
if (!gradingSheet) return;
|
||||
const autoScores: Record<string, number> = {};
|
||||
for (const q of gradingSheet.questions) {
|
||||
if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore;
|
||||
}
|
||||
if (Object.keys(autoScores).length) {
|
||||
setScores((prev) => ({ ...autoScores, ...prev }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gradingSheet]);
|
||||
|
||||
const seafarerOptions = (registrations ?? [])
|
||||
.filter((registration) =>
|
||||
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
|
||||
@@ -175,6 +202,7 @@ export function RecordResultModal({
|
||||
questionRemarks,
|
||||
onScoreChange: handleScoreChange,
|
||||
onRemarkChange: handleQuestionRemarkChange,
|
||||
answersByQuestion,
|
||||
})}
|
||||
data={pagedQuestions.rows}
|
||||
itemCount={pagedQuestions.itemCount}
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
displaySeafarerAnswer,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
useLocalized,
|
||||
type SeafarerRegistration,
|
||||
type SeafarerRegistrationStatus,
|
||||
} from '@ema-platform/api';
|
||||
@@ -28,12 +30,16 @@ export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middl
|
||||
export function SeafarerRegistrationQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [status, setStatus] = useState<SeafarerRegistrationStatus | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [page, setPage] = useState(0);
|
||||
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({
|
||||
status: status ?? undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
@@ -69,7 +75,11 @@ export function SeafarerRegistrationQueuePage() {
|
||||
{
|
||||
header: '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',
|
||||
@@ -100,7 +110,7 @@ export function SeafarerRegistrationQueuePage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[showDate],
|
||||
[showDate, departmentOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
displaySeafarerAnswer,
|
||||
extractErrorMessage,
|
||||
useApproveSeafarerRegistrationMutation,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useGetSeafarerRegistrationReviewQuery,
|
||||
useLocalized,
|
||||
useRejectSeafarerRegistrationMutation,
|
||||
useRequestSeafarerRegistrationChangesMutation,
|
||||
} from '@ema-platform/api';
|
||||
@@ -31,7 +33,10 @@ const DECISION_COPY: Record<Decision, { title: string; label: string; color: str
|
||||
export function SeafarerRegistrationReviewPage() {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const localized = useLocalized();
|
||||
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 [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
|
||||
@@ -166,7 +171,9 @@ export function SeafarerRegistrationReviewPage() {
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{displaySeafarerAnswer(field, registration[field])}</Text>
|
||||
<Text size="sm">
|
||||
{displaySeafarerAnswer(field, registration[field], departmentOptions)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
@@ -100,6 +100,11 @@ const UM_CONFIG: DesignConfig = {
|
||||
|
||||
const UM_RUNTIME = {
|
||||
basename: '/um',
|
||||
// Keep the embedded IAM module on the same API as the backoffice client.
|
||||
// Without a fallback, passing undefined made iamui fall back to its remote
|
||||
// development server, where the local JWT is rejected and the module
|
||||
// redirects to its login page. BASE_API_URL is the one shared resolution of
|
||||
// VITE_BASE_API_URL and carries the local fallback.
|
||||
apiUrl: BASE_API_URL,
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export const am: Translations = {
|
||||
typeVESSEL_OWNERSHIP_TRANSFER: "የመርከብ ባለቤትነት ዝውውር",
|
||||
typeCERTIFICATE_OF_COMPETENCY: "የብቃት ማረጋገጫ ምስክር ወረቀት",
|
||||
typeCERTIFICATE_OF_PROFICIENCY: "የብቃት ምስክር ወረቀት",
|
||||
typeENDORSEMENT_SEAFARER: "የባህረኛ ማስተያየት (CoC / GOC)",
|
||||
typeENDORSEMENT_COC: "የCoC ማረጋገጫ",
|
||||
typeENDORSEMENT_GOC: "የGOC ማረጋገጫ",
|
||||
primary: "ዋና",
|
||||
@@ -87,8 +88,7 @@ export const am: Translations = {
|
||||
btcQueue: "የBTC ወረፋ",
|
||||
cocQueue: "የCoC ወረፋ",
|
||||
copQueue: "የCoP ወረፋ",
|
||||
endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",
|
||||
endorsementGocQueue: "የGOC ማረጋገጫ ወረፋ",
|
||||
endorsementQueue: "የማስተያየት ወረፋ",
|
||||
vesselRegistrations: "የመርከብ ምዝገባ",
|
||||
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
|
||||
@@ -260,7 +260,6 @@ export const am: Translations = {
|
||||
both: "ሁለቱም",
|
||||
offline: "ከመስመር ውጪ",
|
||||
online: "በመስመር",
|
||||
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
||||
sum: "ድምር",
|
||||
average: "አማካይ",
|
||||
percentage: "መቶኛ",
|
||||
@@ -272,6 +271,8 @@ export const am: Translations = {
|
||||
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
|
||||
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
|
||||
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||
next: "ቀጣይ",
|
||||
back: "ተመለስ",
|
||||
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||
status: "ሁኔታ",
|
||||
statusPlaceholder: "የፈተና ሁኔታ",
|
||||
@@ -388,6 +389,9 @@ export const am: Translations = {
|
||||
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
||||
cannotReachCuttingPoint:
|
||||
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
||||
paperLocked: "ወረቀቱ ተቆልፏል",
|
||||
paperLockedHint:
|
||||
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -658,6 +662,9 @@ export const am: Translations = {
|
||||
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
|
||||
scorePerQuestion: "በጥያቄ ውጤት",
|
||||
question: "ጥያቄ",
|
||||
candidateAnswer: "የተፈታኙ መልስ",
|
||||
noAnswer: "የተመዘገበ መልስ የለም",
|
||||
autoGraded: "በራስ-ሰር የተመዘነ",
|
||||
maxPoints: "ከፍተኛ ውጤት",
|
||||
score: "ውጤት",
|
||||
remark: "ማስታወሻ",
|
||||
@@ -899,6 +906,11 @@ export const am: Translations = {
|
||||
submitted: "የቀረበበት",
|
||||
sla: "ዕድሜ / የጊዜ ገደብ",
|
||||
claim: "ውሰድ",
|
||||
assign: "መድብ",
|
||||
assigned: "ተመድቧል",
|
||||
assignedBody: "ባለሙያው ተነግሮታል፤ ግምገማው ተጀምሯል።",
|
||||
assignFailed: "መመደብ አልተቻለም",
|
||||
assignError: "ማመልከቻውን መመደብ አልተቻለም።",
|
||||
review: "ገምግም",
|
||||
claimed: "ተወስዷል",
|
||||
claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።",
|
||||
@@ -974,9 +986,14 @@ export const am: Translations = {
|
||||
unscheduled: "አልተያዘም",
|
||||
inspectionResult: "የምርመራ ውጤት",
|
||||
findings: "ግኝቶች",
|
||||
findingsEvidence: "አጋዥ ሰነዶች",
|
||||
uploadEvidence: "ሰነድ መጫን",
|
||||
dateTime: "ቀን እና ሰዓት",
|
||||
date: "ቀን",
|
||||
morning: "ጠዋት",
|
||||
afternoon: "ከሰዓት በኋላ",
|
||||
schedule: "ያዝ",
|
||||
pickDate: "መጀመሪያ ቀን እና ሰዓት ይምረጡ",
|
||||
pickDate: "መጀመሪያ ቀን ይምረጡ",
|
||||
passed: "አልፏል",
|
||||
failed: "ወድቋል",
|
||||
round_one: "ዙር {{count}}",
|
||||
@@ -985,6 +1002,10 @@ export const am: Translations = {
|
||||
linkCopied: "አገናኝ ተቀድቷል",
|
||||
actionFailed: "ተግባሩ አልተሳካም",
|
||||
errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም",
|
||||
certificate: "ምስክር ወረቀት",
|
||||
view: "ይመልከቱ",
|
||||
download: "አውርድ",
|
||||
certificateError: "ምስክር ወረቀቱን መክፈት አልተቻለም",
|
||||
hideActivity: "እንቅስቃሴ ደብቅ",
|
||||
showActivity: "እንቅስቃሴ አሳይ",
|
||||
awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።",
|
||||
@@ -998,6 +1019,10 @@ export const am: Translations = {
|
||||
actions: {
|
||||
claim: "ውሰድ",
|
||||
assign: "መድብ",
|
||||
reportReview: "ለቡድን መሪ አሳውቅ",
|
||||
assignInspector: "ምርመራ መድብ",
|
||||
reportInspection: "የምርመራ ውጤት አሳውቅ",
|
||||
assignReviewer: "ገምጋሚ መድብ",
|
||||
escalate: "ወደ ላይ አሳድግ",
|
||||
hold: "አግድ",
|
||||
resume: "ቀጥል",
|
||||
@@ -1026,10 +1051,14 @@ export const am: Translations = {
|
||||
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
||||
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
||||
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
||||
inspectionNotYetDue:
|
||||
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
|
||||
needsDocumentReviews:
|
||||
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
|
||||
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
|
||||
},
|
||||
inspectionFailedBlocked:
|
||||
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
|
||||
reasons: {
|
||||
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
||||
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
|
||||
@@ -1056,6 +1085,7 @@ export const am: Translations = {
|
||||
hold: "ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።",
|
||||
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
|
||||
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
||||
"assign-reviewer": "ማመልከቻ {{number}} ለተመረጠው ሹም ሰጥቶ ግምገማውን ያስጀምራል።",
|
||||
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
||||
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
|
||||
},
|
||||
@@ -1150,6 +1180,7 @@ export const am: Translations = {
|
||||
resume: "ማመልከቻው ቀጥሏል",
|
||||
escalate: "ወደ ላይ አድጓል",
|
||||
assign: "እንደገና ተመድቧል",
|
||||
assignReviewer: "ግምገማ ተመድቧል",
|
||||
scheduled: "ምርመራ ተይዟል",
|
||||
inspectionPassed: "ምርመራ አልፏል",
|
||||
inspectionFailed: "ምርመራ ወድቋል",
|
||||
|
||||
@@ -48,6 +48,7 @@ export const en = {
|
||||
typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer',
|
||||
typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
|
||||
typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
|
||||
typeENDORSEMENT_SEAFARER: 'Seafarer Endorsement (CoC / GOC)',
|
||||
typeENDORSEMENT_COC: 'CoC Endorsement',
|
||||
typeENDORSEMENT_GOC: 'GOC Endorsement',
|
||||
primary: 'Primary',
|
||||
@@ -87,8 +88,7 @@ export const en = {
|
||||
postWaiverQueue: 'Post-Waiver Queue',
|
||||
cocQueue: 'CoC Queue',
|
||||
copQueue: 'CoP Queue',
|
||||
endorsementCocQueue: 'CoC Endorsement Queue',
|
||||
endorsementGocQueue: 'GOC Endorsement Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Ownership Transfer',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
@@ -259,7 +259,6 @@ export const en = {
|
||||
both: 'Both',
|
||||
offline: 'Offline',
|
||||
online: 'Online',
|
||||
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
||||
sum: 'Sum',
|
||||
average: 'Average',
|
||||
percentage: 'Percentage',
|
||||
@@ -272,6 +271,8 @@ export const en = {
|
||||
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
|
||||
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
|
||||
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
|
||||
next: 'Next',
|
||||
back: 'Back',
|
||||
status: 'Status',
|
||||
statusPlaceholder: 'Exam status',
|
||||
pending: 'Pending',
|
||||
@@ -387,6 +388,9 @@ export const en = {
|
||||
'Not enough approved questions in the bank for this subject.',
|
||||
cannotReachCuttingPoint:
|
||||
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
|
||||
paperLocked: 'Paper locked',
|
||||
paperLockedHint:
|
||||
'{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.',
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -658,6 +662,9 @@ export const en = {
|
||||
seafarerPlaceholder: 'Search and select a seafarer',
|
||||
scorePerQuestion: 'Score per Question',
|
||||
question: 'Question',
|
||||
candidateAnswer: "Candidate's Answer",
|
||||
noAnswer: 'No answer on file',
|
||||
autoGraded: 'Auto-graded',
|
||||
maxPoints: 'Max Points',
|
||||
score: 'Score',
|
||||
remark: 'Remark',
|
||||
@@ -908,6 +915,11 @@ export const en = {
|
||||
submitted: 'Submitted',
|
||||
sla: 'Age / SLA',
|
||||
claim: 'Claim',
|
||||
assign: 'Assign',
|
||||
assigned: 'Assigned',
|
||||
assignedBody: 'The employee has been notified and the review has started.',
|
||||
assignFailed: 'Could not assign',
|
||||
assignError: 'The application could not be assigned.',
|
||||
review: 'Review',
|
||||
claimed: 'Claimed',
|
||||
claimedBody: 'The application is now assigned to you.',
|
||||
@@ -983,9 +995,14 @@ export const en = {
|
||||
unscheduled: 'Not scheduled',
|
||||
inspectionResult: 'Inspection result',
|
||||
findings: 'Findings',
|
||||
findingsEvidence: 'Supporting documents',
|
||||
uploadEvidence: 'Upload document',
|
||||
dateTime: 'Date and time',
|
||||
date: 'Date',
|
||||
morning: 'Morning',
|
||||
afternoon: 'Afternoon',
|
||||
schedule: 'Schedule',
|
||||
pickDate: 'Pick a date and time first',
|
||||
pickDate: 'Pick a date first',
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
round_one: 'round {{count}}',
|
||||
@@ -994,6 +1011,10 @@ export const en = {
|
||||
linkCopied: 'Link copied',
|
||||
actionFailed: 'Action failed',
|
||||
errorTitle: 'Could not load this application',
|
||||
certificate: 'Certificate',
|
||||
view: 'View',
|
||||
download: 'Download',
|
||||
certificateError: 'Could not open the certificate',
|
||||
hideActivity: 'Hide activity',
|
||||
showActivity: 'Show activity',
|
||||
awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
|
||||
@@ -1007,6 +1028,10 @@ export const en = {
|
||||
actions: {
|
||||
claim: 'Claim',
|
||||
assign: 'Assign',
|
||||
reportReview: 'Report to team leader',
|
||||
assignInspector: 'Assign inspection',
|
||||
reportInspection: 'Report inspection result',
|
||||
assignReviewer: 'Assign reviewer',
|
||||
escalate: 'Escalate',
|
||||
hold: 'Put on hold',
|
||||
resume: 'Resume',
|
||||
@@ -1035,10 +1060,14 @@ export const en = {
|
||||
needsFlags: 'Flag at least one item to request a correction',
|
||||
needsCapital: 'Record the verified capital first',
|
||||
needsInspection: 'Requires an inspection result',
|
||||
inspectionNotYetDue:
|
||||
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
|
||||
needsDocumentReviews:
|
||||
'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
|
||||
needsDocumentsUploaded: 'No documents uploaded to review yet',
|
||||
},
|
||||
inspectionFailedBlocked:
|
||||
'Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
|
||||
reasons: {
|
||||
incompleteDocuments: 'Incomplete documents',
|
||||
belowCapital: 'Capital below the required minimum',
|
||||
@@ -1064,6 +1093,8 @@ export const en = {
|
||||
hold: 'Parks application {{number}} for {{applicant}}. It stays assigned to you.',
|
||||
resume: 'Returns application {{number}} to the stage it was held from.',
|
||||
escalate: 'Raises application {{number}} to a supervisor for a decision.',
|
||||
'assign-reviewer':
|
||||
'Hands application {{number}} to the chosen officer and starts the review.',
|
||||
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
||||
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
|
||||
},
|
||||
@@ -1155,6 +1186,7 @@ export const en = {
|
||||
resume: 'Application resumed',
|
||||
escalate: 'Escalated',
|
||||
assign: 'Reassigned',
|
||||
assignReviewer: 'Review assigned',
|
||||
scheduled: 'Inspection scheduled',
|
||||
inspectionPassed: 'Inspection passed',
|
||||
inspectionFailed: 'Inspection failed',
|
||||
|
||||
@@ -101,8 +101,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_SEAFARER', label: 'nav.endorsementQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
],
|
||||
@@ -112,8 +111,8 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
items: [
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -90,7 +90,11 @@ const router = createBrowserRouter([
|
||||
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
||||
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
// Endorsement review happens in the config-driven licence queue.
|
||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
||||
// CoC and GOC endorsement now share one combined type; the two old
|
||||
// type queues (ENDORSEMENT_COC / ENDORSEMENT_GOC) are left routing
|
||||
// through the generic `:typeCode` queue below rather than redirected,
|
||||
// so an application filed before the switch stays reachable there.
|
||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_SEAFARER" replace /> },
|
||||
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useBypassPaymentMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -31,9 +32,17 @@ import {
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
import { endorsementColumns } from './columns';
|
||||
|
||||
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
|
||||
// ENDORSEMENT_SEAFARER covers CoC and GOC together and is the only type new
|
||||
// applications file against; the other two stay listed so an application or
|
||||
// licence filed before the switch keeps showing up here.
|
||||
const ENDORSEMENT_TYPE_KEYS = [
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
];
|
||||
|
||||
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
@@ -74,6 +83,8 @@ export function EndorsementPage() {
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const issuedTable = useServerTable();
|
||||
|
||||
const registered =
|
||||
@@ -90,6 +101,22 @@ export function EndorsementPage() {
|
||||
);
|
||||
const issuedPage = issuedTable.paginate(issued);
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notify.success(
|
||||
result.certificateIssued
|
||||
? t('endorsement.bypassIssued', 'Payment bypassed — the endorsement has been issued.')
|
||||
: t('endorsement.bypassOk', {
|
||||
defaultValue: 'Payment bypassed — application is now {{status}}.',
|
||||
status: result.status.replace(/_/g, ' ').toLowerCase(),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, t('endorsement.bypassFailed', 'Bypass failed')));
|
||||
}
|
||||
}
|
||||
|
||||
async function download(licenseId: string) {
|
||||
try {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
@@ -132,21 +159,13 @@ export function EndorsementPage() {
|
||||
/>
|
||||
</List>
|
||||
</div>
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
|
||||
>
|
||||
{t('endorsement.endorseCoc', 'Endorse a CoC')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
|
||||
>
|
||||
{t('endorsement.endorseGoc', 'Endorse a GOC')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
disabled={!registered}
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_SEAFARER/apply')}
|
||||
>
|
||||
{t('endorsement.apply', 'Apply for an endorsement')}
|
||||
</Button>
|
||||
</Group>
|
||||
{!registered && (
|
||||
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
||||
@@ -183,6 +202,36 @@ export function EndorsementPage() {
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
|
||||
</Badge>
|
||||
{app.status === 'PAYMENT_PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="yellow"
|
||||
loading={isPaying}
|
||||
onClick={() => pay(app.id)}
|
||||
>
|
||||
{t('endorsement.pay', {
|
||||
defaultValue: 'Pay {{amount}} {{currency}}',
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
{/* ponytail: shown unconditionally for the testing
|
||||
phase — the server still refuses it unless
|
||||
ALLOW_PAYMENT_BYPASS is set and NODE_ENV is not
|
||||
production. Re-gate on useGetPaymentCapabilitiesQuery
|
||||
(like MyApplicationsPage) before prod. */}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
loading={bypassing}
|
||||
onClick={() => handleBypass(app.id)}
|
||||
title="Testing only — marks the fee paid"
|
||||
>
|
||||
{t('endorsement.bypass', 'Bypass payment')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
SaveState,
|
||||
} from '../types/exam-attempt';
|
||||
|
||||
/** Mirrors the server's allow-list (ExamAttemptService.MAY_SIT). */
|
||||
const MAY_SIT = ['PRESENT', 'LATE'];
|
||||
|
||||
const ESSAY_DEBOUNCE_MS = 1500;
|
||||
|
||||
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
||||
@@ -83,6 +86,19 @@ export function useExamAttempt(examId: string | undefined) {
|
||||
setErrorMessage('You are not registered for this examination.');
|
||||
return;
|
||||
}
|
||||
// Attendance gates the sitting, and the exam list already hides "Take
|
||||
// exam" for these — this is the direct-link path. The server refuses
|
||||
// either way (ExamAttemptService.MAY_SIT); this only makes the refusal
|
||||
// legible instead of a raw error key on a Start button that never works.
|
||||
if (!MAY_SIT.includes(registration.attendanceStatus)) {
|
||||
setViewState('error');
|
||||
setErrorMessage(
|
||||
registration.attendanceStatus === 'REGISTERED'
|
||||
? 'An invigilator must confirm you are present before this exam opens.'
|
||||
: 'Your attendance record does not permit sitting this examination.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mineData) {
|
||||
seedFrom(mineData);
|
||||
return;
|
||||
@@ -200,7 +216,14 @@ export function useExamAttempt(examId: string | undefined) {
|
||||
}).unwrap();
|
||||
seedFrom(result);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
|
||||
const key = extractErrorMessage(error, 'Could not start the exam.');
|
||||
notify.error(
|
||||
key === 'attendance_not_confirmed'
|
||||
? 'An invigilator must confirm you are present before this exam opens.'
|
||||
: key === 'candidate_not_present'
|
||||
? 'Your attendance record does not permit sitting this examination.'
|
||||
: key,
|
||||
);
|
||||
}
|
||||
}, [examId, startTrigger, seedFrom]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
@@ -20,7 +20,14 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||
/**
|
||||
* Attendance rulings that permit sitting the paper — an allow-list mirroring
|
||||
* the server's (ExamAttemptService.MAY_SIT). The deny-list this replaced named
|
||||
* only ABSENT/WITHDRAWN/DISQUALIFIED, so the default REGISTERED fell through
|
||||
* and "Take exam" appeared before any invigilator had confirmed the candidate
|
||||
* was there. LATE counts: a late arrival is present, just not on time.
|
||||
*/
|
||||
const MAY_SIT: AttendanceStatus[] = ['PRESENT', 'LATE'];
|
||||
|
||||
export function registrationColumns(
|
||||
t: TFunction,
|
||||
@@ -115,9 +122,26 @@ export function registrationColumns(
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const eligible =
|
||||
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
||||
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
if (!deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
// Say why the exam is shut rather than rendering an empty cell: the
|
||||
// candidate is waiting on an invigilator, and silence reads as a bug.
|
||||
if (row.original.attendanceStatus === 'REGISTERED') {
|
||||
return (
|
||||
<Tooltip label={t('exams.columns.awaitingAttendanceHint')} multiline w={240}>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t('exams.columns.awaitingAttendance')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (!MAY_SIT.includes(row.original.attendanceStatus)) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t('exams.columns.notSitting')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (exam?.status !== 'ACTIVE') return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
conditionHolds,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useGetRanksQuery,
|
||||
useLocalized,
|
||||
type Bilingual,
|
||||
type Department,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type Rank,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
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.
|
||||
*
|
||||
@@ -105,6 +145,21 @@ export function ConfigDrivenSection({
|
||||
(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 (
|
||||
<Grid>
|
||||
{fields.map((field) => {
|
||||
@@ -183,10 +238,7 @@ export function ConfigDrivenSection({
|
||||
) : field.type === 'SELECT' ? (
|
||||
<Select
|
||||
{...common}
|
||||
data={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label),
|
||||
}))}
|
||||
data={selectOptions(field, value as string | undefined, departments, ranks, localized)}
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
clearable={!field.required}
|
||||
|
||||
@@ -322,24 +322,44 @@ export function LicenseApplicationPage() {
|
||||
const isAdjusting = application?.status === "RESUBMIT_REQUIRED";
|
||||
const openRemarks = detail?.openRemarks ?? [];
|
||||
|
||||
// The whole round's remarks, resolved or not — the server's section lock
|
||||
// (`assertSectionUnlocked`) ignores `isResolved`, and this page bulk-resolves
|
||||
// remarks right before resubmitting, so `openRemarks` would re-freeze a
|
||||
// section the moment the applicant ticked it off.
|
||||
const roundRemarks = useMemo(
|
||||
() =>
|
||||
(detail?.remarks ?? []).filter(
|
||||
(r) => r.roundNumber === detail?.application?.adjustmentRound,
|
||||
),
|
||||
[detail?.remarks, detail?.application?.adjustmentRound],
|
||||
);
|
||||
|
||||
const flaggedSections = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks
|
||||
roundRemarks
|
||||
.filter((r) => r.targetType === "FORM_SECTION")
|
||||
.map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
[roundRemarks],
|
||||
);
|
||||
const flaggedDocuments = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks
|
||||
roundRemarks
|
||||
.filter((r) => r.targetType === "DOCUMENT")
|
||||
.map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
[roundRemarks],
|
||||
);
|
||||
const hasSectionRemarks = Object.keys(flaggedSections).length > 0;
|
||||
const hasDocRemarks = Object.keys(flaggedDocuments).length > 0;
|
||||
|
||||
// A round that flagged no form sections carries no section locks — mirror of
|
||||
// the server's fallback, without which a documents-only correction round
|
||||
// froze every field and the applicant could not edit anything at all.
|
||||
const isSectionLocked = (sectionKey: string) =>
|
||||
isAdjusting && hasSectionRemarks && !flaggedSections[sectionKey];
|
||||
|
||||
// Sections that share a group collapse onto one step, so the stepper stays
|
||||
// short instead of showing a page per section.
|
||||
@@ -356,6 +376,14 @@ export function LicenseApplicationPage() {
|
||||
[steps],
|
||||
);
|
||||
|
||||
// A section-level showWhen can remove a step while the wizard is open
|
||||
// (ENDORSEMENT_SEAFARER's certificate sections follow the chosen scope).
|
||||
// Clamp so `steps[active]` can never go out of bounds if a seed ever lets
|
||||
// a later answer hide an earlier step.
|
||||
useEffect(() => {
|
||||
if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1));
|
||||
}, [active, steps.length]);
|
||||
|
||||
if (loadingConfig || !config || !appId || !application) {
|
||||
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
||||
}
|
||||
@@ -375,6 +403,24 @@ export function LicenseApplicationPage() {
|
||||
// to the summary first.
|
||||
const showSummary = application.status !== "DRAFT" && viewingSummary;
|
||||
|
||||
// The applicant reads "Vessel Particulars", not "vesselParticulars" — and a
|
||||
// staff remark is keyed by a uuid, which reads as nothing at all.
|
||||
function remarkLabel(remark: (typeof openRemarks)[number]): string {
|
||||
if (remark.targetType === "FORM_SECTION") {
|
||||
const section = config?.licenseType.formSchema.sections.find(
|
||||
(s) => s.key === remark.targetKey,
|
||||
);
|
||||
return section ? localized(section.title) : remark.targetKey;
|
||||
}
|
||||
if (remark.targetType === "DOCUMENT") {
|
||||
const requirement = config?.documentRequirements.find(
|
||||
(r) => r.key === remark.targetKey,
|
||||
);
|
||||
return requirement ? localized(requirement.name) : remark.targetKey;
|
||||
}
|
||||
return t("licenseApplication.staffMember", "Staff member");
|
||||
}
|
||||
|
||||
// Vessel Information and Current Ownership are separate form sections, so
|
||||
// ConfigDrivenSection (one instance per section) can't fill both itself —
|
||||
// it reports the pick up here and this fans it out across every section.
|
||||
@@ -392,7 +438,7 @@ export function LicenseApplicationPage() {
|
||||
async function saveSection(sectionKey: string) {
|
||||
// During an adjustment round only flagged sections are editable, so don't
|
||||
// even attempt a write the server would reject.
|
||||
if (isAdjusting && !flaggedSections[sectionKey]) return;
|
||||
if (isSectionLocked(sectionKey)) return;
|
||||
const values = { ...(draft[sectionKey] ?? {}) };
|
||||
// The picker works in alpha-2 codes (CountrySelect); the backend, like
|
||||
// the profile Address endpoint, stores the full country name.
|
||||
@@ -671,7 +717,7 @@ export function LicenseApplicationPage() {
|
||||
<Stack gap={4}>
|
||||
{openRemarks.map((remark) => (
|
||||
<Text size="sm" key={remark.id}>
|
||||
<b>{remark.targetKey}</b>: {remark.remark}
|
||||
<b>{remarkLabel(remark)}</b>: {remark.remark}
|
||||
</Text>
|
||||
))}
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
@@ -733,7 +779,7 @@ export function LicenseApplicationPage() {
|
||||
{currentStep?.kind === "sections" && (
|
||||
<Stack gap="lg">
|
||||
{currentStep.sections.map((section, index) => {
|
||||
const locked = isAdjusting && !flaggedSections[section.key];
|
||||
const locked = isSectionLocked(section.key);
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{index > 0 && <Divider mb="lg" />}
|
||||
@@ -891,7 +937,7 @@ export function LicenseApplicationPage() {
|
||||
ownerType="APPLICATION"
|
||||
ownerId={appId}
|
||||
flagged={flaggedDocuments}
|
||||
restrictToFlagged={isAdjusting}
|
||||
restrictToFlagged={isAdjusting && hasDocRemarks}
|
||||
readOnly={readOnly}
|
||||
onUploaded={() => {
|
||||
refetchAttachments();
|
||||
@@ -914,7 +960,10 @@ export function LicenseApplicationPage() {
|
||||
formData={draft}
|
||||
errors={fieldErrors}
|
||||
vessels={vessels}
|
||||
disabled={readOnly}
|
||||
// Same lock as the earlier steps — without it this step
|
||||
// looked editable during an adjustment round while
|
||||
// saveSection silently dropped the changes.
|
||||
disabled={readOnly || isSectionLocked(section.key)}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -55,6 +55,10 @@ const MODE_FREE_TYPE_KEYS = [
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
// Retired by ENDORSEMENT_SEAFARER but kept mode-free: an in-flight
|
||||
// application filed against one of these before the switch must still be
|
||||
// reachable to view, correct or resubmit.
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
'PRE_WAIVER',
|
||||
|
||||
@@ -16,10 +16,16 @@ import { OperationsFormContent } from "../../profile/components/OperationsFormCo
|
||||
* Seafarer goes to its own registration page, whose Identity Details step
|
||||
* collects the profile answers itself — no detour via `/profile`. Seafarer
|
||||
* wins when both are ticked; the other form is one nav click away.
|
||||
*
|
||||
* SEAFARER_REGISTRATION is listed before ENDORSEMENT_SEAFARER deliberately:
|
||||
* `nextStepFor` takes the first key that matches, and an applicant who ticked
|
||||
* both belongs in registration first — the endorsement application refuses
|
||||
* submission until that registration is accepted.
|
||||
*/
|
||||
const NEXT_STEP: Record<string, string> = {
|
||||
SEAFARER_REGISTRATION: "/seafarer-registration",
|
||||
VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply",
|
||||
VESSEL_REGISTRATION: "/vessel-registration",
|
||||
ENDORSEMENT_SEAFARER: "/endorsements",
|
||||
};
|
||||
|
||||
function nextStepFor(selectedKeys: string[]): string {
|
||||
|
||||
@@ -25,15 +25,21 @@ import { notify, ModalFooter } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/**
|
||||
* Registrations an applicant makes for themselves rather than for a company.
|
||||
* Registrations and seafarer-only services an applicant declares for
|
||||
* themselves rather than for a company.
|
||||
*
|
||||
* Named explicitly rather than inferred from `requiresOperatorMode: false`,
|
||||
* because that flag is also false for things nobody declares up front — a
|
||||
* waiver is requested per shipment, not adopted as an identity.
|
||||
* ENDORSEMENT_SEAFARER belongs here for the same reason as the two
|
||||
* registrations: a seafarer requesting one is not declaring a logistics mode
|
||||
* of operation, and without this key `accountTypeFor` below would fall the
|
||||
* account through to the company-representative type instead of `SEAFARER`.
|
||||
*/
|
||||
const PERSONAL_REGISTRATION_KEYS = [
|
||||
'SEAFARER_REGISTRATION',
|
||||
'VESSEL_REGISTRATION',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,6 +62,7 @@ function accountTypeFor(keys: string[]): string | null {
|
||||
}
|
||||
if (keys.includes('VESSEL_REGISTRATION')) return 'VESSEL_OWNER';
|
||||
if (keys.includes('SEAFARER_REGISTRATION')) return 'SEAFARER';
|
||||
if (keys.includes('ENDORSEMENT_SEAFARER')) return 'SEAFARER';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -212,7 +219,7 @@ export function OperationsFormContent({
|
||||
{personalOptions.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" fw={600} c="dimmed" mt="sm" tt="uppercase">
|
||||
Registering as an individual or vessel owner
|
||||
Registering or applying as an individual or vessel owner
|
||||
</Text>
|
||||
{personalOptions.map((type) => (
|
||||
<Checkbox
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } fr
|
||||
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
isEthiopianNationality,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
} from '@ema-platform/api';
|
||||
@@ -10,22 +11,25 @@ import {
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** 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) => ({
|
||||
...d,
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required,
|
||||
})).filter((d) => d.required !== 'passport' || passportDeclared);
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
|
||||
})).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
|
||||
}
|
||||
|
||||
export function RegistrationDocuments({
|
||||
registrationId,
|
||||
passportDeclared,
|
||||
nationality,
|
||||
attachments,
|
||||
readOnly,
|
||||
onUploaded,
|
||||
}: {
|
||||
registrationId: string;
|
||||
passportDeclared: boolean;
|
||||
nationality?: string | null;
|
||||
attachments: Attachment[];
|
||||
readOnly?: boolean;
|
||||
onUploaded: () => void;
|
||||
@@ -62,7 +66,7 @@ export function RegistrationDocuments({
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{documentSlots(passportDeclared).map((slot) => {
|
||||
{documentSlots(passportDeclared, nationality).map((slot) => {
|
||||
const existing = attachments.find((a) => a.documentKey === slot.key);
|
||||
const uploaded = Boolean(existing?.files?.length);
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_SECTIONS,
|
||||
displaySeafarerAnswer,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
type Attachment,
|
||||
type SaveSeafarerRegistration,
|
||||
} from '@ema-platform/api';
|
||||
@@ -16,6 +18,10 @@ export function RegistrationSummary({
|
||||
answers: SaveSeafarerRegistration;
|
||||
attachments?: Attachment[];
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
|
||||
@@ -35,7 +41,9 @@ export function RegistrationSummary({
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text>
|
||||
<Text size="sm">
|
||||
{displaySeafarerAnswer(field, answers[field], departmentOptions)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
GENDER_OPTIONS,
|
||||
HAIR_COLOR_OPTIONS,
|
||||
MARITAL_STATUS_OPTIONS,
|
||||
isEthiopianNationality,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
DateField,
|
||||
@@ -42,6 +45,7 @@ function SectionTitle({ title, description }: { title: string; description?: str
|
||||
export function IdentityDetailsStep(
|
||||
p: StepProps & { account: { email?: string; phoneNumber?: string } },
|
||||
) {
|
||||
const ethiopian = isEthiopianNationality(p.form.nationality);
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Contact Details" />
|
||||
@@ -66,26 +70,55 @@ export function IdentityDetailsStep(
|
||||
<DateField {...p} name="dateOfBirth" label="Date of Birth" required />
|
||||
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
|
||||
<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>
|
||||
</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) {
|
||||
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 (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Identity" />
|
||||
<Grid>
|
||||
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
maxLength={32}
|
||||
description="Required later for international sea service; optional at registration."
|
||||
/>
|
||||
{ethiopian && (
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
maxLength={32}
|
||||
description="Required later for international sea service; optional at registration."
|
||||
/>
|
||||
)}
|
||||
{p.form.passportNumber && (
|
||||
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
|
||||
)}
|
||||
@@ -94,7 +127,7 @@ export function ApplicantDetailsStep(p: StepProps) {
|
||||
name="department"
|
||||
label="Department"
|
||||
required
|
||||
options={DEPARTMENT_OPTIONS}
|
||||
options={departmentOptions}
|
||||
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -8,13 +9,14 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Title,
|
||||
} 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 {
|
||||
PHYSICAL_BOUNDS,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
isEthiopianNationality,
|
||||
useCancelSeafarerRegistrationMutation,
|
||||
useGetAttachmentsQuery,
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
@@ -48,15 +52,26 @@ const STEPS = [
|
||||
{ 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[][] = [
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
|
||||
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||
[],
|
||||
['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[];
|
||||
|
||||
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
|
||||
@@ -102,6 +117,12 @@ function withProfileDefaults(
|
||||
emergencyContactPhone: a?.emergencyContactPhone || null,
|
||||
emergencyContactRelationship: a?.emergencyContactRelation || null,
|
||||
department: profile.seafarerDepartment || null,
|
||||
// Deepest saved level the picker's maxDepth of 3 can show.
|
||||
locationId: a?.subCityId || a?.cityId || a?.regionId || null,
|
||||
hairColor: (profile.hairColor as SaveSeafarerRegistration['hairColor']) || null,
|
||||
eyeColor: (profile.eyeColor as SaveSeafarerRegistration['eyeColor']) || null,
|
||||
bloodType: (profile.bloodType as SaveSeafarerRegistration['bloodType']) || null,
|
||||
heightCm: profile.heightCm ?? null,
|
||||
};
|
||||
const next = { ...answers };
|
||||
for (const [key, value] of Object.entries(defaults) as [AnswerKey, unknown][]) {
|
||||
@@ -119,15 +140,18 @@ function withProfileDefaults(
|
||||
* still missing. A submitted registration opens to a read-only summary.
|
||||
*/
|
||||
export function SeafarerRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const accountUser = useAppSelector((state) => state.auth.user);
|
||||
const { profile } = useCurrentProfile();
|
||||
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
|
||||
const registration = data?.registration ?? null;
|
||||
|
||||
const [start] = useStartSeafarerRegistrationMutation();
|
||||
const [cancelDraft, { isLoading: cancelling }] = useCancelSeafarerRegistrationMutation();
|
||||
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
|
||||
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
||||
const started = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,7 +228,7 @@ export function SeafarerRegistrationPage() {
|
||||
|
||||
function validateStep(index: number): boolean {
|
||||
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 (index === 1) {
|
||||
@@ -232,7 +256,7 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
if (index === 3) {
|
||||
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))
|
||||
.map((d) => d.name);
|
||||
if (missing.length) {
|
||||
@@ -302,6 +326,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 };
|
||||
|
||||
return (
|
||||
@@ -319,17 +356,30 @@ export function SeafarerRegistrationPage() {
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
{showSummary && !readOnly && (
|
||||
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
||||
Edit details
|
||||
</Button>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
{registration.status === 'DRAFT' && (
|
||||
<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>
|
||||
|
||||
{registration.status === 'APPROVED' && (
|
||||
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
|
||||
You are a registered seafarer. Your seafarer number is <b>{registration.seafarerNumber}</b>.
|
||||
Your Seaman Book and Basic Training Certificate applications have been opened for you.
|
||||
You can now apply for a certificate endorsement from the Endorsement Seafarer page.
|
||||
</Alert>
|
||||
)}
|
||||
{registration.status === 'REJECTED' && (
|
||||
@@ -386,6 +436,7 @@ export function SeafarerRegistrationPage() {
|
||||
<RegistrationDocuments
|
||||
registrationId={registration.id}
|
||||
passportDeclared={Boolean(form.passportNumber)}
|
||||
nationality={form.nationality}
|
||||
attachments={attachments}
|
||||
readOnly={readOnly}
|
||||
onUploaded={refetchAttachments}
|
||||
@@ -426,6 +477,23 @@ export function SeafarerRegistrationPage() {
|
||||
</Group>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,6 +298,22 @@ function SeaServiceTab() {
|
||||
})
|
||||
: 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 =
|
||||
form.vesselName.trim().length > 1 &&
|
||||
form.rank.trim().length > 1 &&
|
||||
@@ -391,6 +407,7 @@ function SeaServiceTab() {
|
||||
required
|
||||
value={form.vesselName}
|
||||
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
|
||||
error={vesselNameError}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('seaRecords.seaService.fields.imoNumber')}
|
||||
@@ -418,9 +435,13 @@ function SeaServiceTab() {
|
||||
</Group>
|
||||
<TextInput
|
||||
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
|
||||
value={form.rank}
|
||||
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
||||
error={rankError}
|
||||
/>
|
||||
<Group grow>
|
||||
<AmharicDatePicker
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
/** Columns for the applicant's in-flight vessel registration applications. */
|
||||
export function inFlightColumns(
|
||||
t: TFunction,
|
||||
deps: { onOpen: (app: LicenseApplication) => void },
|
||||
deps: {
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
onBypass: (app: LicenseApplication) => void;
|
||||
isPaying: boolean;
|
||||
bypassing: boolean;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication>[] {
|
||||
return [
|
||||
{
|
||||
@@ -38,15 +44,50 @@ export function inFlightColumns(
|
||||
},
|
||||
{
|
||||
header: t('applications.table.progress'),
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[row.original.status]}
|
||||
color={STATUS_COLORS[row.original.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
),
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const app = row.original;
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[app.status]}
|
||||
color={STATUS_COLORS[app.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
{/* The fee stops the registration dead, so the payment action sits
|
||||
on the bar rather than being hidden behind View. */}
|
||||
{app.status === 'PAYMENT_PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="yellow"
|
||||
loading={deps.isPaying}
|
||||
onClick={() => deps.onPay(app)}
|
||||
>
|
||||
{t('applications.actions.pay', {
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
{/* ponytail: shown unconditionally — the licensing page hides
|
||||
this behind the API's bypassEnabled capability flag, which
|
||||
is off here. Re-gate on capabilities before prod. */}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
loading={deps.bypassing}
|
||||
onClick={() => deps.onBypass(app)}
|
||||
title="Testing only — marks the fee paid"
|
||||
>
|
||||
{t('applications.actions.bypass')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
|
||||
@@ -30,11 +30,15 @@ import {
|
||||
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
|
||||
import { inFlightColumns } from '../inFlightColumns';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
TERMINAL_STATUSES,
|
||||
useApiMutation,
|
||||
useBypassPaymentMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -132,6 +136,8 @@ export function VesselRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [page, setPage] = useState(0);
|
||||
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||
@@ -155,9 +161,32 @@ export function VesselRegistrationPage() {
|
||||
!TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Payment bypassed',
|
||||
message: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Bypass failed',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns = inFlightColumns(t, {
|
||||
onOpen: (app) =>
|
||||
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
|
||||
// Paying leaves the SPA for Telebirr — a provider hand-off, not a route change.
|
||||
onPay: (app) => pay(app.id),
|
||||
onBypass: (app) => handleBypass(app.id),
|
||||
isPaying,
|
||||
bypassing,
|
||||
});
|
||||
|
||||
const certs = registration?.category === 'Sea-going Vessel (International)'
|
||||
|
||||
@@ -63,7 +63,7 @@ export const am: Translations = {
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
endorsements: 'የባህረኛ ማስተያየት',
|
||||
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||
documents: 'ሰነዶቼ',
|
||||
@@ -242,6 +242,7 @@ export const am: Translations = {
|
||||
RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል',
|
||||
INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ',
|
||||
INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል',
|
||||
INSPECTION_FAILED: 'ቁጥጥር አልተሳካም',
|
||||
APPROVED: 'ጸድቋል',
|
||||
REJECTED: 'ውድቅ ተደርጓል',
|
||||
ON_HOLD: 'ላይ ቆሟል',
|
||||
@@ -1000,6 +1001,10 @@ export const am: Translations = {
|
||||
timeExpired: 'ጊዜው አልቋል',
|
||||
resumeExam: 'ፈተና ይቀጥሉ',
|
||||
takeExam: 'ፈተና ይውሰዱ',
|
||||
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
|
||||
awaitingAttendanceHint:
|
||||
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
|
||||
notSitting: 'አይፈተኑም',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
@@ -1030,8 +1035,7 @@ export const am: Translations = {
|
||||
registered: "የተመዘገበ መርከበኛ ({{number}})",
|
||||
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
|
||||
},
|
||||
endorseCoc: "CoC ያረጋግጡ",
|
||||
endorseGoc: "GOC ያረጋግጡ",
|
||||
apply: "ለማስተያየት ያመልክቱ",
|
||||
registrationNotice: {
|
||||
prefix: "መጀመሪያ የ",
|
||||
link: "መርከበኛ ምዝገባዎን",
|
||||
|
||||
@@ -63,7 +63,7 @@ export const en = {
|
||||
certificates: 'Certificates',
|
||||
seamanBook: 'SeamanBook and BTC',
|
||||
btc: 'Basic Training Certificate',
|
||||
endorsements: 'Endorsements',
|
||||
endorsements: 'Endorsement Seafarer',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Transfers',
|
||||
documents: 'My Documents',
|
||||
@@ -242,6 +242,7 @@ export const en = {
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
INSPECTION_FAILED: 'Inspection Failed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
@@ -1002,6 +1003,10 @@ export const en = {
|
||||
timeExpired: 'Time expired',
|
||||
resumeExam: 'Resume exam',
|
||||
takeExam: 'Take exam',
|
||||
awaitingAttendance: 'Awaiting attendance',
|
||||
awaitingAttendanceHint:
|
||||
'An invigilator must confirm you are present before the exam opens.',
|
||||
notSitting: 'Not sitting',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'Not called',
|
||||
PRESENT: 'Present',
|
||||
@@ -1032,8 +1037,7 @@ export const en = {
|
||||
registered: 'Registered seafarer ({{number}})',
|
||||
registrationRequired: 'Active seafarer registration required',
|
||||
},
|
||||
endorseCoc: 'Endorse a CoC',
|
||||
endorseGoc: 'Endorse a GOC',
|
||||
apply: 'Apply for an endorsement',
|
||||
registrationNotice: {
|
||||
prefix: 'Complete your',
|
||||
link: 'seafarer registration',
|
||||
|
||||
@@ -140,7 +140,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
},
|
||||
{
|
||||
to: "/endorsements",
|
||||
label: "Endorsements",
|
||||
label: "Endorsement Seafarer",
|
||||
i18nKey: "nav.endorsements",
|
||||
icon: IconRubberStamp,
|
||||
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||
|
||||
@@ -21,9 +21,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
value={{
|
||||
appName: 'Portal',
|
||||
storagePrefix: 'ema-portal',
|
||||
// Applicants land on the licence list rather than the seafarer
|
||||
// dashboard: signing up here is the first step of applying.
|
||||
loginRedirectPath: '/licensing/applications',
|
||||
loginRedirectPath: '/dashboard',
|
||||
enableSignup: true,
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
|
||||
@@ -511,6 +511,16 @@ export const licensingApi = baseApi
|
||||
query: (id) => ({ url: `/licenses/${id}/certificate` }),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Same download link, backoffice side. Separate endpoint from
|
||||
* `getCertificateUrl`: the applicant route only ever hands the
|
||||
* certificate to its holder, and an officer reviewing what they just
|
||||
* issued is never the holder.
|
||||
*/
|
||||
getCertificateUrlForOfficer: builder.mutation<{ url: string }, string>({
|
||||
query: (id) => ({ url: `/licenses/${id}/certificate-backoffice` }),
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------- review
|
||||
getQueue: builder.query<Paginated<LicenseApplication>, QueueFilter | void>({
|
||||
query: (params) => ({
|
||||
@@ -842,6 +852,53 @@ export const licensingApi = baseApi
|
||||
query: () => ({ url: '/license-application-review/officers' }),
|
||||
}),
|
||||
|
||||
// ------------------------------------------------- team-leader dispatch
|
||||
/**
|
||||
* Handing work out, and handing it back.
|
||||
*
|
||||
* `assignApplication` below only re-points an application already in
|
||||
* flight; these two *start* a stage, because under the push model
|
||||
* assignment is how work begins — nothing is claimed from a queue.
|
||||
*/
|
||||
assignInspector: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; inspectorId: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/assign-inspector`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
reportReview: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/report-review`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
reportInspection: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/report-inspection`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
// ----------------------------------------------------- workflow controls
|
||||
assignApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
@@ -856,6 +913,23 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Starts the review: the team leader hands the file to an employee.
|
||||
* `assign` above only re-points an application already in flight.
|
||||
*/
|
||||
assignReviewer: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; officerId: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/assign-reviewer`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
holdApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; reason: string }
|
||||
@@ -929,7 +1003,12 @@ export const licensingApi = baseApi
|
||||
// --------------------------------------------------------- inspection
|
||||
scheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
{ applicationId: string; scheduledDate: string; location?: string }
|
||||
{
|
||||
applicationId: string;
|
||||
scheduledDate: string;
|
||||
timeSlot: 'MORNING' | 'AFTERNOON';
|
||||
location?: string;
|
||||
}
|
||||
>({
|
||||
query: (body) => ({ url: '/inspections', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
@@ -1017,6 +1096,7 @@ export const {
|
||||
useGetMyLicensesQuery,
|
||||
useGetLicensesQuery,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetCertificateUrlForOfficerMutation,
|
||||
useGetApplicationPaymentQuery,
|
||||
usePatchSectionMutation,
|
||||
useAddStaffMutation,
|
||||
@@ -1050,6 +1130,10 @@ export const {
|
||||
useRevokeLicenseMutation,
|
||||
useReinstateLicenseMutation,
|
||||
useAssignApplicationMutation,
|
||||
useAssignReviewerMutation,
|
||||
useAssignInspectorMutation,
|
||||
useReportReviewMutation,
|
||||
useReportInspectionMutation,
|
||||
useHoldApplicationMutation,
|
||||
useResumeApplicationMutation,
|
||||
useEscalateApplicationMutation,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
FamilyKind,
|
||||
FieldCondition,
|
||||
FormFieldConfig,
|
||||
FormSectionConfig,
|
||||
LicenseApplication,
|
||||
@@ -63,7 +64,10 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
REVIEW_REPORTED: 'Review Reported',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
INSPECTION_REPORTED: 'Inspection Reported',
|
||||
INSPECTION_FAILED: 'Inspection Failed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
@@ -90,7 +94,13 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
UNDER_EVALUATION: 'indigo',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'cyan',
|
||||
// Both 'reported' states are waiting on the team leader, so they carry the
|
||||
// same tone as anything else awaiting an officer decision.
|
||||
REVIEW_REPORTED: 'orange',
|
||||
INSPECTION_COMPLETED: 'cyan',
|
||||
INSPECTION_REPORTED: 'orange',
|
||||
// Orange, not red: recoverable — a re-inspection can still pass.
|
||||
INSPECTION_FAILED: 'orange',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
ON_HOLD: 'gray',
|
||||
@@ -123,7 +133,11 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
UNDER_EVALUATION: 45,
|
||||
RESUBMIT_REQUIRED: 30,
|
||||
INSPECTION_PENDING: 55,
|
||||
REVIEW_REPORTED: 50,
|
||||
INSPECTION_COMPLETED: 65,
|
||||
INSPECTION_REPORTED: 70,
|
||||
// A re-inspection returns to the pending step, so no further along than it.
|
||||
INSPECTION_FAILED: 55,
|
||||
APPROVED: 75,
|
||||
// Parked, so it keeps the progress of wherever it was held from.
|
||||
ON_HOLD: 45,
|
||||
@@ -177,6 +191,7 @@ export const APPLICANT_NAME_TYPE_KEYS = [
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'VESSEL_REGISTRATION',
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
];
|
||||
@@ -188,6 +203,7 @@ const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
|
||||
BTC_BASIC_TRAINING: 'CERTIFICATE',
|
||||
CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
|
||||
CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
|
||||
ENDORSEMENT_SEAFARER: 'CERTIFICATE',
|
||||
ENDORSEMENT_COC: 'CERTIFICATE',
|
||||
ENDORSEMENT_GOC: 'CERTIFICATE',
|
||||
FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
|
||||
@@ -371,6 +387,10 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
application_not_awaiting_inspection:
|
||||
'This application is not waiting for an inspection.',
|
||||
inspection_already_completed: 'This inspection has already been recorded.',
|
||||
inspection_not_yet_due:
|
||||
'Inspection results can be recorded after the scheduled inspection date and time.',
|
||||
inspection_not_passed:
|
||||
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
|
||||
license_type_inactive: 'This licence type is not currently accepting applications.',
|
||||
};
|
||||
|
||||
@@ -555,7 +575,14 @@ export function validateSections(
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Evaluates a config condition against the current form answers. */
|
||||
/**
|
||||
* Evaluates a config condition against the current form answers.
|
||||
*
|
||||
* Mirrors the server's `ApplicationValidationService.conditionHolds` —
|
||||
* `anyOf` holds when any listed sub-condition holds, needed for an answer
|
||||
* that can live on one of several mutually-exclusive fields (e.g. a CoP rank
|
||||
* split by department).
|
||||
*/
|
||||
interface ConditionLike {
|
||||
field?: string;
|
||||
equals?: unknown;
|
||||
@@ -567,7 +594,7 @@ interface ConditionLike {
|
||||
}
|
||||
|
||||
export function conditionHolds(
|
||||
condition: ConditionLike | undefined | null,
|
||||
condition: FieldCondition | undefined | null,
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (!condition) return true;
|
||||
|
||||
@@ -26,9 +26,17 @@ export type LicenseStatus =
|
||||
| "SUBMITTED"
|
||||
| "UNDER_REVIEW"
|
||||
| "UNDER_EVALUATION"
|
||||
// Employee filed their review; parked with the team leader for a decision.
|
||||
| "REVIEW_REPORTED"
|
||||
| "RESUBMIT_REQUIRED"
|
||||
| "INSPECTION_PENDING"
|
||||
| "INSPECTION_COMPLETED"
|
||||
// Inspector filed the result; parked with the team leader for a decision.
|
||||
| "INSPECTION_REPORTED"
|
||||
// The inspection was conducted and failed. Approval and issuance are
|
||||
// unreachable until a re-inspection passes; the officer chooses between a
|
||||
// repeat visit, an adjustment round, and rejection.
|
||||
| "INSPECTION_FAILED"
|
||||
| "APPROVED"
|
||||
| "REJECTED"
|
||||
| "ON_HOLD"
|
||||
@@ -74,9 +82,9 @@ export interface FieldCondition {
|
||||
in?: (string | number)[];
|
||||
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.
|
||||
* Alternative to a single-field check: holds when ANY listed condition
|
||||
* holds. `field`/`equals`/etc are ignored when this is present. Mirrors
|
||||
* the server's `FieldCondition` (form-schema.type.ts).
|
||||
*/
|
||||
anyOf?: FieldCondition[];
|
||||
}
|
||||
@@ -446,6 +454,8 @@ export interface Inspection {
|
||||
inspectorId: string | null;
|
||||
inspectorName: string | null;
|
||||
scheduledDate: string | null;
|
||||
/** Half-day slot the site visit is booked into. */
|
||||
timeSlot: "MORNING" | "AFTERNOON" | null;
|
||||
conductedDate: string | null;
|
||||
location: string | null;
|
||||
status: "SCHEDULED" | "COMPLETED" | "CANCELLED";
|
||||
@@ -543,6 +553,13 @@ export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
|
||||
export interface TemplatePageOptions {
|
||||
format?: "A4" | "A5" | "Letter" | "Legal";
|
||||
/**
|
||||
* Explicit page dimensions (e.g. "4.92in"), for a size `format` has no
|
||||
* named preset for — an ID-3 passport-booklet page, for one. Takes
|
||||
* precedence over `format` when both are present.
|
||||
*/
|
||||
width?: string;
|
||||
height?: string;
|
||||
landscape?: boolean;
|
||||
printBackground?: boolean;
|
||||
}
|
||||
@@ -572,12 +589,15 @@ export interface TemplateFieldPlacement {
|
||||
/** Variable rendered here, or null when the block carries literal `text`. */
|
||||
variable: string | null;
|
||||
text?: string;
|
||||
/** Renders as `<img>` when "image" — see TemplateVariable.kind. */
|
||||
type?: "text" | "image";
|
||||
xPct: number;
|
||||
yPct: number;
|
||||
widthPct: number;
|
||||
fontSize?: number;
|
||||
fontWeight?: "normal" | "bold";
|
||||
align?: "left" | "center" | "right";
|
||||
fontStyle?: "normal" | "italic";
|
||||
align?: "left" | "center" | "right" | "justify";
|
||||
color?: string;
|
||||
}
|
||||
|
||||
@@ -639,6 +659,8 @@ export interface LicenseTemplate {
|
||||
export interface TemplateVariable {
|
||||
key: string;
|
||||
label: string;
|
||||
/** "image" means the value is a data URI to place as `<img>`, not text. */
|
||||
kind?: "text" | "image";
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
|
||||
@@ -36,6 +36,11 @@ export const seafarerRegistrationApi = baseApi
|
||||
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<
|
||||
SeafarerRegistration,
|
||||
{ id: string; body: SaveSeafarerRegistration }
|
||||
@@ -108,6 +113,7 @@ export const seafarerRegistrationApi = baseApi
|
||||
export const {
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useStartSeafarerRegistrationMutation,
|
||||
useCancelSeafarerRegistrationMutation,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
useSubmitSeafarerRegistrationMutation,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
|
||||
@@ -64,13 +64,28 @@ export const PHYSICAL_BOUNDS = {
|
||||
weightKg: { min: 30, max: 250 },
|
||||
} 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. */
|
||||
export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
key: string;
|
||||
name: 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;
|
||||
}[] = [
|
||||
{
|
||||
@@ -80,7 +95,7 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
required: true,
|
||||
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: 'graduation', name: 'Educational Certificate', required: false },
|
||||
{
|
||||
@@ -210,14 +225,22 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
|
||||
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(
|
||||
field: keyof SeafarerRegistrationAnswers,
|
||||
value: unknown,
|
||||
departmentOptions?: { value: string; label: string }[],
|
||||
): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
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);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"backoffice": "nx serve @ema-platform/backoffice",
|
||||
"portal": "nx serve @ema-platform/portal",
|
||||
"dev:all": "nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
|
||||
"build": "nx run-many -t build -p @ema-platform/portal @ema-platform/backoffice",
|
||||
"build:backoffice": "nx build @ema-platform/backoffice",
|
||||
"build:portal": "nx build @ema-platform/portal",
|
||||
"lint": "nx run-many -t lint",
|
||||
|
||||
Reference in New Issue
Block a user