Merge remote-tracking branch 'origin/dev' into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-27 13:19:51 +00:00
52 changed files with 1678 additions and 307 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

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

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

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

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

@@ -99,7 +99,11 @@ const UM_CONFIG: DesignConfig = {
const UM_RUNTIME = {
basename: '/um',
apiUrl: import.meta.env.VITE_BASE_API_URL,
// Keep the embedded IAM module on the same API as the backoffice client.
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
// fall back to its remote development server, where the local JWT is
// rejected and the module redirects to its login page.
apiUrl: import.meta.env.VITE_BASE_API_URL ?? 'http://localhost:3000/api',
};
const buttonStyle: React.CSSProperties = {

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: "የመርከብ ባለቤትነት ዝውውር",
@@ -906,6 +906,11 @@ export const am: Translations = {
submitted: "የቀረበበት",
sla: "ዕድሜ / የጊዜ ገደብ",
claim: "ውሰድ",
assign: "መድብ",
assigned: "ተመድቧል",
assignedBody: "ባለሙያው ተነግሮታል፤ ግምገማው ተጀምሯል።",
assignFailed: "መመደብ አልተቻለም",
assignError: "ማመልከቻውን መመደብ አልተቻለም።",
review: "ገምግም",
claimed: "ተወስዷል",
claimedBody: "ማመልከቻው አሁን ለእርስዎ ተመድቧል።",
@@ -981,9 +986,14 @@ export const am: Translations = {
unscheduled: "አልተያዘም",
inspectionResult: "የምርመራ ውጤት",
findings: "ግኝቶች",
findingsEvidence: "አጋዥ ሰነዶች",
uploadEvidence: "ሰነድ መጫን",
dateTime: "ቀን እና ሰዓት",
date: "ቀን",
morning: "ጠዋት",
afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
pickDate: "መጀመሪያ ቀን እና ሰዓት ይምረጡ",
pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
round_one: "ዙር {{count}}",
@@ -992,6 +1002,10 @@ export const am: Translations = {
linkCopied: "አገናኝ ተቀድቷል",
actionFailed: "ተግባሩ አልተሳካም",
errorTitle: "ይህን ማመልከቻ መጫን አልተቻለም",
certificate: "ምስክር ወረቀት",
view: "ይመልከቱ",
download: "አውርድ",
certificateError: "ምስክር ወረቀቱን መክፈት አልተቻለም",
hideActivity: "እንቅስቃሴ ደብቅ",
showActivity: "እንቅስቃሴ አሳይ",
awaitingPayment: "አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።",
@@ -1005,6 +1019,10 @@ export const am: Translations = {
actions: {
claim: "ውሰድ",
assign: "መድብ",
reportReview: "ለቡድን መሪ አሳውቅ",
assignInspector: "ምርመራ መድብ",
reportInspection: "የምርመራ ውጤት አሳውቅ",
assignReviewer: "ገምጋሚ መድብ",
escalate: "ወደ ላይ አሳድግ",
hold: "አግድ",
resume: "ቀጥል",
@@ -1033,10 +1051,14 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
inspectionNotYetDue:
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
needsDocumentReviews:
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
},
inspectionFailedBlocked:
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች",
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
@@ -1063,6 +1085,7 @@ export const am: Translations = {
hold: "ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።",
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
"assign-reviewer": "ማመልከቻ {{number}} ለተመረጠው ሹም ሰጥቶ ግምገማውን ያስጀምራል።",
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
},
@@ -1157,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',
@@ -915,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.',
@@ -990,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}}',
@@ -1001,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}}.',
@@ -1014,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',
@@ -1042,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',
@@ -1071,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}}.',
},
@@ -1162,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 />) },