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:
mihretue
2026-08-27 14:03:33 +00:00
60 changed files with 1969 additions and 372 deletions

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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 };
}

View File

@@ -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 "&amp;" 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`;
}

View File

@@ -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,

View File

@@ -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());

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">

View File

@@ -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],

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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) => ({

View File

@@ -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>

View File

@@ -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';

View File

@@ -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: {

View File

@@ -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>
) : (

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
});
});

View File

@@ -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');
}

View File

@@ -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'),

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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>
))}

View File

@@ -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,
};

View File

@@ -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: "ምርመራ ወድቋል",

View File

@@ -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',

View File

@@ -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] },
],
},

View File

@@ -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 />) },

View File

@@ -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"

View File

@@ -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]);

View File

@@ -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"

View File

@@ -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}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 (

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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

View File

@@ -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: '',

View File

@@ -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)'

View File

@@ -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: "መርከበኛ ምዝገባዎን",

View File

@@ -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',

View File

@@ -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],

View File

@@ -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,
}}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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",