mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 15:18:12 +00:00
Merge branch 'WorkflowChange' into logestic_chnage
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
ColorInput,
|
||||
Group,
|
||||
NumberInput,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api';
|
||||
|
||||
@@ -43,7 +44,17 @@ export function BlockPropertiesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const isLiteral = block.variable === null;
|
||||
const current = block;
|
||||
const isLiteral = current.variable === null;
|
||||
const isImage = current.type === 'image';
|
||||
const variableByKey = new Map(variables.map((v) => [v.key, v]));
|
||||
|
||||
const selectVariable = (key: string | null) => {
|
||||
if (!key) return;
|
||||
const kind: 'text' | 'image' =
|
||||
variableByKey.get(key)?.kind === 'image' ? 'image' : 'text';
|
||||
onChange({ ...current, variable: key, type: kind });
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
@@ -70,11 +81,9 @@ export function BlockPropertiesPanel({
|
||||
value={isLiteral ? 'text' : 'variable'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
value === 'text'
|
||||
? { ...block, variable: null, text: block.text ?? '' }
|
||||
: { ...block, variable: variables[0]?.key ?? 'companyName' },
|
||||
)
|
||||
value === 'text'
|
||||
? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' })
|
||||
: selectVariable(variables[0]?.key ?? 'companyName')
|
||||
}
|
||||
data={[
|
||||
{ value: 'variable', label: t('designer.blockVariable', 'Variable') },
|
||||
@@ -94,24 +103,26 @@ export function BlockPropertiesPanel({
|
||||
label={t('designer.blockVariableLabel', 'Variable')}
|
||||
data={variables.map((variable) => ({
|
||||
value: variable.key,
|
||||
label: variable.label,
|
||||
label: variable.kind === 'image' ? `🖼 ${variable.label}` : variable.label,
|
||||
}))}
|
||||
value={block.variable}
|
||||
onChange={(value) => onChange({ ...block, variable: value })}
|
||||
onChange={selectVariable}
|
||||
searchable
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group gap="xs" grow>
|
||||
<NumberInput
|
||||
label={t('designer.blockFontSize', 'Font size')}
|
||||
value={block.fontSize ?? 14}
|
||||
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
|
||||
min={4}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!isImage && (
|
||||
<NumberInput
|
||||
label={t('designer.blockFontSize', 'Font size')}
|
||||
value={block.fontSize ?? 14}
|
||||
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
|
||||
min={4}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
label={t('designer.blockWidth', 'Width (%)')}
|
||||
value={block.widthPct}
|
||||
@@ -149,42 +160,62 @@ export function BlockPropertiesPanel({
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={block.align ?? 'left'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
|
||||
}
|
||||
data={[
|
||||
{ value: 'left', label: t('designer.alignLeft', 'Left') },
|
||||
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
|
||||
{ value: 'right', label: t('designer.alignRight', 'Right') },
|
||||
]}
|
||||
/>
|
||||
{!isImage && (
|
||||
<>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={block.align ?? 'left'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
|
||||
}
|
||||
data={[
|
||||
{ value: 'left', label: t('designer.alignLeft', 'Left') },
|
||||
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
|
||||
{ value: 'right', label: t('designer.alignRight', 'Right') },
|
||||
{ value: 'justify', label: t('designer.alignJustify', 'Justify') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={block.fontWeight ?? 'normal'}
|
||||
disabled={disabled}
|
||||
onChange={(value) =>
|
||||
onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] })
|
||||
}
|
||||
data={[
|
||||
{ value: 'normal', label: t('designer.weightNormal', 'Normal') },
|
||||
{ value: 'bold', label: t('designer.weightBold', 'Bold') },
|
||||
]}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={block.fontWeight === 'bold' ? 'filled' : 'default'}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...block,
|
||||
fontWeight: block.fontWeight === 'bold' ? 'normal' : 'bold',
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconBold size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={block.fontStyle === 'italic' ? 'filled' : 'default'}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...block,
|
||||
fontStyle: block.fontStyle === 'italic' ? 'normal' : 'italic',
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconItalic size={14} />
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ColorInput
|
||||
label={t('designer.blockColor', 'Colour')}
|
||||
value={block.color ?? '#111111'}
|
||||
onChange={(value) => onChange({ ...block, color: value })}
|
||||
disabled={disabled}
|
||||
format="hex"
|
||||
/>
|
||||
<ColorInput
|
||||
label={t('designer.blockColor', 'Colour')}
|
||||
value={block.color ?? '#111111'}
|
||||
onChange={(value) => onChange({ ...block, color: value })}
|
||||
disabled={disabled}
|
||||
format="hex"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized, type LicenseType } from '@ema-platform/api';
|
||||
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
|
||||
import { groupedTypeOptions } from '../config/designer';
|
||||
|
||||
interface Props {
|
||||
licenseTypes: LicenseType[];
|
||||
typeId: string | null;
|
||||
onTypeChange: (id: string | null) => void;
|
||||
/** The selected licence type's rank ladder — empty for non-CoC/CoP types. */
|
||||
ranks: Rank[];
|
||||
rankId: string | null;
|
||||
onRankChange: (id: string | null) => void;
|
||||
validityMonths: number;
|
||||
onValidityChange: (months: number) => void;
|
||||
currentValidityMonths?: number | null;
|
||||
@@ -22,6 +26,9 @@ export function DesignerToolbar({
|
||||
licenseTypes,
|
||||
typeId,
|
||||
onTypeChange,
|
||||
ranks,
|
||||
rankId,
|
||||
onRankChange,
|
||||
validityMonths,
|
||||
onValidityChange,
|
||||
currentValidityMonths,
|
||||
@@ -49,6 +56,23 @@ export function DesignerToolbar({
|
||||
w={340}
|
||||
/>
|
||||
|
||||
{/* CoC/CoP only — a rank can carry its own design (e.g. Master's
|
||||
certificate differs from an OOW's). "Default" (null) is the design
|
||||
every other rank under the type falls back to. */}
|
||||
{ranks.length > 0 && (
|
||||
<Select
|
||||
label={t('designer.rank', 'Rank')}
|
||||
description={t('designer.rankHint', 'Leave as Default to design for every rank')}
|
||||
data={[
|
||||
{ value: '', label: t('designer.rankDefault', 'Default (all ranks)') },
|
||||
...ranks.map((r) => ({ value: r.id, label: localized(r.name) })),
|
||||
]}
|
||||
value={rankId ?? ''}
|
||||
onChange={(value) => onRankChange(value || null)}
|
||||
w={220}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Validity lives beside the design because it is the other half of
|
||||
what a certificate promises. */}
|
||||
<NumberInput
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -257,6 +258,7 @@ export function TemplateCanvas({
|
||||
|
||||
{placements.map((block) => {
|
||||
const isSelected = block.id === selectedId;
|
||||
const isImage = block.type === 'image';
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
@@ -271,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',
|
||||
@@ -283,9 +286,30 @@ export function TemplateCanvas({
|
||||
lineHeight: 1.3,
|
||||
wordWrap: 'break-word',
|
||||
userSelect: 'none',
|
||||
// An image block has no real image to show here — the actual
|
||||
// data URI is only resolved server-side at render/preview
|
||||
// time — so it gets a fixed square footprint and an icon
|
||||
// instead of stretching to a text block's shape.
|
||||
...(isImage
|
||||
? {
|
||||
aspectRatio: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: isSelected
|
||||
? 'rgba(34,139,230,0.08)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{block.variable ? `{{${block.variable}}}` : block.text || ' '}
|
||||
{isImage ? (
|
||||
<IconPhoto size={18} color="var(--mantine-color-gray-6)" />
|
||||
) : block.variable ? (
|
||||
`{{${block.variable}}}`
|
||||
) : (
|
||||
block.text || ' '
|
||||
)}
|
||||
|
||||
{isSelected && !disabled && (
|
||||
<span
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { IconPhoto, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Variable {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
import type { TemplateVariable } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
variables: Variable[];
|
||||
variables: TemplateVariable[];
|
||||
disabled: boolean;
|
||||
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
|
||||
canvasMode: boolean;
|
||||
onInsert: (key: string) => void;
|
||||
onAddBlock: (key: string) => void;
|
||||
onAddBlock: (key: string, kind: 'text' | 'image') => void;
|
||||
onAddTextBlock: () => void;
|
||||
}
|
||||
|
||||
@@ -60,8 +56,13 @@ export function TemplateVariableList({
|
||||
variant="default"
|
||||
justify="flex-start"
|
||||
disabled={disabled}
|
||||
leftSection={
|
||||
variable.kind === 'image' ? <IconPhoto size={12} /> : undefined
|
||||
}
|
||||
onClick={() =>
|
||||
canvasMode ? onAddBlock(variable.key) : onInsert(variable.key)
|
||||
canvasMode
|
||||
? onAddBlock(variable.key, variable.kind === 'image' ? 'image' : 'text')
|
||||
: onInsert(variable.key)
|
||||
}
|
||||
>
|
||||
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
||||
|
||||
@@ -41,14 +41,43 @@ function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string {
|
||||
return ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable keys the renderer fills with a data URI — the fallback for a
|
||||
* block placed before `type` existed on it. Keep in sync with
|
||||
* `IMAGE_VARIABLE_KEYS` in the server's template-variables.ts; a new image
|
||||
* variable added there should be added here too.
|
||||
*/
|
||||
const IMAGE_VARIABLE_KEYS = new Set([
|
||||
'logo',
|
||||
'holderPhoto',
|
||||
'qrImage',
|
||||
'sealImage',
|
||||
'signatureImage',
|
||||
'seafarerSignature',
|
||||
]);
|
||||
|
||||
function isImageBlock(block: TemplateFieldPlacement): boolean {
|
||||
if (block.type) return block.type === 'image';
|
||||
return !!block.variable && IMAGE_VARIABLE_KEYS.has(block.variable);
|
||||
}
|
||||
|
||||
function blockHtml(block: TemplateFieldPlacement): string {
|
||||
const x = pct(block.xPct, 0);
|
||||
const y = pct(block.yPct, 0);
|
||||
// Minimum 1%, matching the server compiler: a zero-width block would render
|
||||
// as an invisible sliver rather than as the mistake it is.
|
||||
const width = pct(block.widthPct, 30, 1);
|
||||
|
||||
if (isImageBlock(block) && block.variable) {
|
||||
// Triple-brace: the value is a data URI, not markup — escaping it turns
|
||||
// every "&" into "&" and corrupts the src.
|
||||
const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;object-fit:contain;`;
|
||||
return ` <img class="ema-block" style="${style}" src="{{{${block.variable}}}}" alt="" />\n`;
|
||||
}
|
||||
|
||||
const size = block.fontSize ?? 14;
|
||||
const weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
|
||||
const style_ = block.fontStyle === 'italic' ? 'italic' : 'normal';
|
||||
const align = block.align ?? 'left';
|
||||
const color = escapeHtml(block.color ?? '#111111');
|
||||
|
||||
@@ -58,7 +87,7 @@ function blockHtml(block: TemplateFieldPlacement): string {
|
||||
|
||||
const style =
|
||||
`position:absolute;left:${x}%;top:${y}%;width:${width}%;` +
|
||||
`font-size:${size}px;font-weight:${weight};text-align:${align};color:${color};`;
|
||||
`font-size:${size}px;font-weight:${weight};font-style:${style_};text-align:${align};color:${color};`;
|
||||
|
||||
return ` <div class="ema-block" style="${style}">${content}</div>\n`;
|
||||
}
|
||||
|
||||
@@ -90,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) =>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useGetBuiltInTemplateQuery,
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetRanksQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
@@ -66,14 +67,19 @@ export function CertificateDesignerPage() {
|
||||
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const [typeId, setTypeId] = useState<string | null>(null);
|
||||
const [rankId, setRankId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: templates = [],
|
||||
data: allTemplates = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
|
||||
// The list is per licence type; a rank-specific design and the type's
|
||||
// default both come back, so the version list is scoped to whichever the
|
||||
// toolbar has selected.
|
||||
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
|
||||
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
||||
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
||||
|
||||
@@ -95,6 +101,27 @@ export function CertificateDesignerPage() {
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
|
||||
// A rank ladder only exists for CoC/CoP — every other licence type designs
|
||||
// one certificate for everyone who holds it. Keyed on `key`, not
|
||||
// `certificateCategory`: that STCW-mapping column is unset on the seeded
|
||||
// CoC/CoP rows (it's authored later, per StcwMappingPanel), while `key` is
|
||||
// the stable identity CertificateEligibilityService itself branches on.
|
||||
// CoC/CoP are each a single LicenseType spanning every department's ladder
|
||||
// (the applicant's own department, not the type, decides which ladder they
|
||||
// climb), so the picker offers every rank in the ladder across all
|
||||
// departments rather than one department's.
|
||||
const rankCategory: 'COC' | 'COP' | null =
|
||||
selectedType?.key === 'CERTIFICATE_OF_COMPETENCY'
|
||||
? 'COC'
|
||||
: selectedType?.key === 'CERTIFICATE_OF_PROFICIENCY'
|
||||
? 'COP'
|
||||
: null;
|
||||
const isRankScoped = rankCategory !== null;
|
||||
const { data: allRanks } = useGetRanksQuery(undefined, { skip: !isRankScoped });
|
||||
const ranks = (allRanks?.items ?? [])
|
||||
.filter((r) => r.certificateCategory === rankCategory)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
// Default to the first licence type so the page is never an empty shell.
|
||||
useEffect(() => {
|
||||
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
||||
@@ -104,6 +131,12 @@ export function CertificateDesignerPage() {
|
||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||
}, [selectedType]);
|
||||
|
||||
// Switching licence type leaves a stale rank selected from the previous
|
||||
// type's ladder — reset to the type's default design.
|
||||
useEffect(() => {
|
||||
setRankId(null);
|
||||
}, [typeId]);
|
||||
|
||||
function startNewVersion() {
|
||||
setNewName(
|
||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||
@@ -130,6 +163,12 @@ export function CertificateDesignerPage() {
|
||||
setTypeId(value);
|
||||
draft.setSelectedId(null);
|
||||
}}
|
||||
ranks={ranks}
|
||||
rankId={rankId}
|
||||
onRankChange={(value) => {
|
||||
setRankId(value);
|
||||
draft.setSelectedId(null);
|
||||
}}
|
||||
validityMonths={validityMonths}
|
||||
onValidityChange={setValidityMonths}
|
||||
currentValidityMonths={selectedType?.validityMonths}
|
||||
@@ -366,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>
|
||||
@@ -382,6 +421,7 @@ export function CertificateDesignerPage() {
|
||||
run(async () => {
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
rankId,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
|
||||
import { ActionIcon, Autocomplete, Checkbox, Group, Paper, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
@@ -7,6 +8,9 @@ import type { ConditionTarget } from '../config/schema-paths';
|
||||
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
|
||||
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
|
||||
|
||||
/** One editable `anyOf` arm — a single-field condition, same shape a plain condition holds. */
|
||||
type ConditionArm = Omit<ConditionValue, 'anyOf' | 'previousDocExpired'>;
|
||||
|
||||
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
|
||||
|
||||
function operatorOf(condition: ConditionValue | undefined): Operator | null {
|
||||
@@ -29,6 +33,196 @@ function coerce(raw: string, targetType: string | undefined): string | number |
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** One-line summary of a condition for read-only chips ("when X = Y", "when X = Y or W = Z"). */
|
||||
export function describeCondition(
|
||||
condition: ConditionValue,
|
||||
t: (key: string, fallback: string) => string,
|
||||
): string {
|
||||
if (condition.anyOf?.length) {
|
||||
return condition.anyOf.map((arm) => describeCondition(arm, t)).join(` ${t('certReq.condition.or', 'or')} `);
|
||||
}
|
||||
if (!condition.field) return '';
|
||||
const parts = [condition.field];
|
||||
if (condition.equals !== undefined) parts.push(`= ${condition.equals}`);
|
||||
if (condition.notEquals !== undefined) parts.push(`≠ ${condition.notEquals}`);
|
||||
if (condition.in !== undefined) parts.push(`∈ [${condition.in.join(', ')}]`);
|
||||
if (condition.isSet !== undefined) {
|
||||
parts.push(condition.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The field/operator/value trio for one condition — a plain condition, or one
|
||||
* arm of an `anyOf`. No enable switch of its own; the caller owns whether
|
||||
* this row exists at all.
|
||||
*/
|
||||
function ConditionArmFields({
|
||||
value,
|
||||
onChange,
|
||||
targets,
|
||||
palette,
|
||||
}: {
|
||||
value: ConditionArm;
|
||||
onChange: (value: ConditionArm) => void;
|
||||
targets: ConditionTarget[];
|
||||
palette: FormSchemaPalette | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const operator = operatorOf(value) ?? 'equals';
|
||||
const target = targets.find((c) => c.path === value.field);
|
||||
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
|
||||
|
||||
function setField(field: string) {
|
||||
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
|
||||
}
|
||||
|
||||
function setOperator(next: Operator) {
|
||||
if (!value.field) return;
|
||||
const base: ConditionArm = { field: value.field };
|
||||
if (next === 'isSet') base.isSet = true;
|
||||
else if (next === 'in') base.in = [];
|
||||
else if (next === 'notEquals') base.notEquals = '';
|
||||
else base.equals = '';
|
||||
onChange(base);
|
||||
}
|
||||
|
||||
function setValueRaw(raw: string) {
|
||||
if (!value.field) return;
|
||||
const coerced = coerce(raw, target?.field.type);
|
||||
if (operator === 'equals') onChange({ field: value.field, equals: coerced });
|
||||
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
|
||||
}
|
||||
|
||||
function setInValues(raws: string[]) {
|
||||
if (!value.field) return;
|
||||
onChange({
|
||||
field: value.field,
|
||||
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Autocomplete
|
||||
label={t('certReq.condition.field', 'Field path')}
|
||||
placeholder="certificate.rank"
|
||||
description={t(
|
||||
'certReq.condition.fieldHelp',
|
||||
'Dot path into the form, e.g. sectionKey.fieldKey',
|
||||
)}
|
||||
data={targets.map((c) => c.path)}
|
||||
value={value.field ?? ''}
|
||||
onChange={setField}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={t('certReq.condition.operator', 'Operator')}
|
||||
data={operators.map((op) => ({ value: op, label: op }))}
|
||||
value={operator}
|
||||
onChange={(v) => v && setOperator(v as Operator)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
|
||||
<Select
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
data={(target.field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label) || o.value,
|
||||
}))}
|
||||
value={String(value.equals ?? value.notEquals ?? '')}
|
||||
onChange={(v) => v !== null && setValueRaw(v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
|
||||
target?.field.type === 'BOOLEAN' ? (
|
||||
<Checkbox
|
||||
mt="xl"
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
checked={Boolean(value.equals ?? value.notEquals ?? false)}
|
||||
onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'}
|
||||
value={String(value.equals ?? value.notEquals ?? '')}
|
||||
onChange={(e) => setValueRaw(e.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{operator === 'in' && target?.field.type === 'SELECT' && (
|
||||
<Select
|
||||
label={t('certReq.condition.values', 'Any of')}
|
||||
data={(target.field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label) || o.value,
|
||||
}))}
|
||||
multiple={undefined}
|
||||
value={null}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
const current = (value.in ?? []) as string[];
|
||||
if (!current.includes(v)) setInValues([...current, v]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operator === 'in' && target?.field.type !== 'SELECT' && (
|
||||
<TextInput
|
||||
label={t('certReq.condition.values', 'Any of (comma-separated)')}
|
||||
value={(value.in ?? []).join(', ')}
|
||||
onChange={(e) =>
|
||||
setInValues(
|
||||
e.currentTarget.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{operator === 'in' && (value.in?.length ?? 0) > 0 && (
|
||||
<Group gap={4}>
|
||||
{(value.in ?? []).map((v, i) => (
|
||||
<Text
|
||||
key={`${v}-${i}`}
|
||||
fz="xs"
|
||||
px={6}
|
||||
py={2}
|
||||
bg="var(--mantine-color-gray-1)"
|
||||
style={{ borderRadius: 4, cursor: 'pointer' }}
|
||||
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
||||
title={t('certReq.condition.removeValue', 'Click to remove')}
|
||||
>
|
||||
{String(v)} ×
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!target && value.field && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t(
|
||||
'certReq.condition.unknownField',
|
||||
'This path is not a field in the current schema yet — it will still be saved as typed.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_ARM: ConditionArm = { field: '', equals: '' };
|
||||
|
||||
/**
|
||||
* Authors one `FieldCondition` (`showWhen` on a section/field, or
|
||||
* `conditionExpression` on a document requirement).
|
||||
@@ -38,6 +232,11 @@ function coerce(raw: string, targetType: string | undefined): string | number |
|
||||
* SELECT field, the value picker switches to that field's own options
|
||||
* instead of free text — the condition can only ever reference an answer
|
||||
* that could actually be chosen.
|
||||
*
|
||||
* "Any of these" switches to authoring several single-field conditions whose
|
||||
* OR is the real condition — needed when the same logical value can live on
|
||||
* one of several mutually-exclusive fields (e.g. a rank split by
|
||||
* department, see FieldCondition.anyOf).
|
||||
*/
|
||||
export function ConditionBuilder({
|
||||
value,
|
||||
@@ -54,40 +253,35 @@ export function ConditionBuilder({
|
||||
allowClear?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const active = value !== null;
|
||||
const operator = operatorOf(value ?? undefined) ?? 'equals';
|
||||
const target = targets.find((c) => c.path === value?.field);
|
||||
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
|
||||
const isAnyOf = Boolean(value?.anyOf);
|
||||
const arms = (value?.anyOf ?? []) as ConditionArm[];
|
||||
|
||||
function setField(field: string) {
|
||||
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
|
||||
function setArm(i: number, arm: ConditionArm) {
|
||||
const next = arms.slice();
|
||||
next[i] = arm;
|
||||
onChange({ anyOf: next });
|
||||
}
|
||||
|
||||
function setOperator(next: Operator) {
|
||||
if (!value?.field) return;
|
||||
const base: ConditionValue = { field: value.field };
|
||||
if (next === 'isSet') base.isSet = true;
|
||||
else if (next === 'in') base.in = [];
|
||||
else if (next === 'notEquals') base.notEquals = '';
|
||||
else base.equals = '';
|
||||
onChange(base);
|
||||
function addArm() {
|
||||
onChange({ anyOf: [...arms, { ...EMPTY_ARM }] });
|
||||
}
|
||||
|
||||
function setValueRaw(raw: string) {
|
||||
if (!value?.field) return;
|
||||
const coerced = coerce(raw, target?.field.type);
|
||||
if (operator === 'equals') onChange({ field: value.field, equals: coerced });
|
||||
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
|
||||
function removeArm(i: number) {
|
||||
onChange({ anyOf: arms.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function setInValues(raws: string[]) {
|
||||
if (!value?.field) return;
|
||||
onChange({
|
||||
field: value.field,
|
||||
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
|
||||
});
|
||||
function toggleAnyOf(next: boolean) {
|
||||
if (next) {
|
||||
// Seed the list from whatever single condition already existed, so
|
||||
// switching modes doesn't discard work in progress.
|
||||
const seed: ConditionArm = value?.field ? (value as ConditionArm) : { ...EMPTY_ARM };
|
||||
onChange({ anyOf: [seed] });
|
||||
} else {
|
||||
// Same, in reverse — the first arm becomes the single condition.
|
||||
onChange((arms[0] as ConditionValue) ?? { field: '', equals: '' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -102,116 +296,58 @@ export function ConditionBuilder({
|
||||
|
||||
{active && (
|
||||
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
|
||||
<Autocomplete
|
||||
label={t('certReq.condition.field', 'Field path')}
|
||||
placeholder="certificate.rank"
|
||||
description={t(
|
||||
'certReq.condition.fieldHelp',
|
||||
'Dot path into the form, e.g. sectionKey.fieldKey',
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t(
|
||||
'certReq.condition.anyOfEnable',
|
||||
'Any of these (the value can live on one of several fields)',
|
||||
)}
|
||||
data={targets.map((c) => c.path)}
|
||||
value={value?.field ?? ''}
|
||||
onChange={setField}
|
||||
checked={isAnyOf}
|
||||
onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={t('certReq.condition.operator', 'Operator')}
|
||||
data={operators.map((op) => ({ value: op, label: op }))}
|
||||
value={operator}
|
||||
onChange={(v) => v && setOperator(v as Operator)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
|
||||
<Select
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
data={(target.field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label) || o.value,
|
||||
}))}
|
||||
value={String(value?.equals ?? value?.notEquals ?? '')}
|
||||
onChange={(v) => v !== null && setValueRaw(v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
|
||||
target?.field.type === 'BOOLEAN' ? (
|
||||
<Checkbox
|
||||
mt="xl"
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
checked={Boolean(value?.equals ?? value?.notEquals ?? false)}
|
||||
onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label={t('certReq.condition.value', 'Value')}
|
||||
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'}
|
||||
value={String(value?.equals ?? value?.notEquals ?? '')}
|
||||
onChange={(e) => setValueRaw(e.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{operator === 'in' && target?.field.type === 'SELECT' && (
|
||||
<Select
|
||||
label={t('certReq.condition.values', 'Any of')}
|
||||
data={(target.field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label) || o.value,
|
||||
}))}
|
||||
multiple={undefined}
|
||||
value={null}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
const current = (value?.in ?? []) as string[];
|
||||
if (!current.includes(v)) setInValues([...current, v]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operator === 'in' && target?.field.type !== 'SELECT' && (
|
||||
<TextInput
|
||||
label={t('certReq.condition.values', 'Any of (comma-separated)')}
|
||||
value={(value?.in ?? []).join(', ')}
|
||||
onChange={(e) =>
|
||||
setInValues(
|
||||
e.currentTarget.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{operator === 'in' && (value?.in?.length ?? 0) > 0 && (
|
||||
<Group gap={4}>
|
||||
{(value?.in ?? []).map((v, i) => (
|
||||
<Text
|
||||
key={`${v}-${i}`}
|
||||
fz="xs"
|
||||
px={6}
|
||||
py={2}
|
||||
bg="var(--mantine-color-gray-1)"
|
||||
style={{ borderRadius: 4, cursor: 'pointer' }}
|
||||
onClick={() => setInValues((value?.in ?? []).filter((_, idx) => idx !== i).map(String))}
|
||||
title={t('certReq.condition.removeValue', 'Click to remove')}
|
||||
>
|
||||
{String(v)} ×
|
||||
</Text>
|
||||
{isAnyOf ? (
|
||||
<Stack gap="sm">
|
||||
{arms.map((arm, i) => (
|
||||
<Paper key={i} withBorder p="sm" radius="sm">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fz="xs" fw={600} c="dimmed">
|
||||
{t('certReq.condition.anyOfArm', 'Condition {{n}}', { n: i + 1 })}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
disabled={arms.length <= 1}
|
||||
onClick={() => removeArm(i)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<ConditionArmFields
|
||||
value={arm}
|
||||
onChange={(next) => setArm(i, next)}
|
||||
targets={targets}
|
||||
palette={palette}
|
||||
/>
|
||||
</Paper>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!target && value?.field && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t(
|
||||
'certReq.condition.unknownField',
|
||||
'This path is not a field in the current schema yet — it will still be saved as typed.',
|
||||
)}
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon variant="light" onClick={addArm}>
|
||||
<IconPlus size={16} />
|
||||
</ActionIcon>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('certReq.condition.anyOfAdd', 'Add another field')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<ConditionArmFields
|
||||
value={value as ConditionArm}
|
||||
onChange={(next) => onChange(next)}
|
||||
targets={targets}
|
||||
palette={palette}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -94,7 +94,16 @@ export function DocumentRequirementEditorDrawer({
|
||||
return;
|
||||
}
|
||||
if (!draft.name.en?.trim()) return;
|
||||
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) {
|
||||
// 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 &&
|
||||
!draft.conditionExpression?.anyOf?.length
|
||||
) {
|
||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||
return;
|
||||
}
|
||||
@@ -167,7 +176,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
<>
|
||||
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
|
||||
<ConditionBuilder
|
||||
value={(draft.conditionExpression ?? null) as ConditionValue | null}
|
||||
value={(draft.conditionExpression ?? { field: '', equals: '' }) as ConditionValue}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
|
||||
targets={conditionTargets}
|
||||
palette={palette}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import { collectConditionTargets } from '../config/schema-paths';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import { describeCondition } from './ConditionBuilder';
|
||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||
|
||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
||||
@@ -137,13 +138,9 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
||||
</Text>
|
||||
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
|
||||
{req.mode === 'CONDITIONAL' && req.conditionExpression && (
|
||||
<Text fz="xs" c="violet">
|
||||
{t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '}
|
||||
{req.conditionExpression.equals !== undefined && `= ${req.conditionExpression.equals}`}
|
||||
{req.conditionExpression.notEquals !== undefined && `≠ ${req.conditionExpression.notEquals}`}
|
||||
{req.conditionExpression.in !== undefined && `∈ [${req.conditionExpression.in.join(', ')}]`}
|
||||
{req.conditionExpression.isSet !== undefined && (req.conditionExpression.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'))}
|
||||
{t('certReq.doc.when', 'when')} {describeCondition(req.conditionExpression, t)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,15 @@ export function certificationColumns(
|
||||
header: t('certification.columns.description'),
|
||||
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.rank', 'Rank'),
|
||||
cell: ({ row }) =>
|
||||
row.original.rankKey ? (
|
||||
<Badge size="sm" variant="outline" color="violet">{row.original.rankKey}</Badge>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">—</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import {Stack, Button, Modal, Text, TextInput, Textarea, Card} from '@mantine/core';
|
||||
import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
|
||||
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 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 }, isEdit: boolean) => void;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -30,6 +33,7 @@ function CertificationForm({
|
||||
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
|
||||
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
||||
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
||||
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -37,7 +41,7 @@ function CertificationForm({
|
||||
notify.error('Name fields are required');
|
||||
return;
|
||||
}
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing);
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -48,6 +52,17 @@ function CertificationForm({
|
||||
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Select
|
||||
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={rankOptions}
|
||||
value={rankKey}
|
||||
onChange={setRankKey}
|
||||
size="sm"
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||
@@ -62,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();
|
||||
@@ -80,15 +98,17 @@ export function CertificationPage() {
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => {
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateCert({ id: editing.id, name, description }).unwrap();
|
||||
// null clears a previously-set rank; undefined would leave it
|
||||
// untouched server-side, so the two are not interchangeable here.
|
||||
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
|
||||
notify.success(t('certification.updated'));
|
||||
} else {
|
||||
await createCert({ name, description }).unwrap();
|
||||
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
||||
notify.success(t('certification.created'));
|
||||
}
|
||||
resetForm();
|
||||
@@ -140,6 +160,7 @@ export function CertificationPage() {
|
||||
{showForm && (
|
||||
<CertificationForm
|
||||
editing={editing}
|
||||
rankOptions={rankOptions}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={resetForm}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Certification {
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
isActive: boolean;
|
||||
rankKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -20,6 +21,7 @@ export interface ListResponse<T> {
|
||||
export interface CreateCertificationPayload {
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
rankKey?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCertificationPayload {
|
||||
@@ -27,4 +29,6 @@ export interface UpdateCertificationPayload {
|
||||
name?: LocalePair;
|
||||
description?: LocalePair;
|
||||
isActive?: boolean;
|
||||
/** Omit to leave unchanged, null to clear a previously-set rank. */
|
||||
rankKey?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconInfoCircle, IconPlus } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AdvancedTable,
|
||||
ModalFooter,
|
||||
notify,
|
||||
PageLoader,
|
||||
useErrorHandler,
|
||||
type AdvancedColumn,
|
||||
} from "@ema-platform/ui";
|
||||
import {
|
||||
useLocalized,
|
||||
useGetDepartmentsQuery,
|
||||
useCreateDepartmentMutation,
|
||||
useUpdateDepartmentMutation,
|
||||
useDeleteDepartmentMutation,
|
||||
useGetRanksQuery,
|
||||
useCreateRankMutation,
|
||||
useUpdateRankMutation,
|
||||
useDeleteRankMutation,
|
||||
type Department,
|
||||
type Rank,
|
||||
type RankCertificateCategory,
|
||||
} from "@ema-platform/api";
|
||||
|
||||
const CATEGORY_OPTIONS: { value: RankCertificateCategory; label: string }[] = [
|
||||
{ value: "COC", label: "CoC" },
|
||||
{ value: "COP", label: "CoP" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Departments and their CoC/CoP rank ladders, as backoffice-editable config.
|
||||
*
|
||||
* Both used to be hardcoded (`ESeafarerDepartment` and the `COC_LADDERS`/
|
||||
* `COP_LADDERS` arrays) — this is the write side that config never had. A
|
||||
* rank's `ladderOrder` is the rung position `resolveNextRank` climbs, so
|
||||
* reordering here changes what an applicant is auto-advanced to next.
|
||||
*/
|
||||
export function RankDepartmentTab() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const { data: deptRes, isLoading: deptLoading, isFetching: deptFetching, refetch: refetchDepts } =
|
||||
useGetDepartmentsQuery();
|
||||
const { data: rankRes, isLoading: rankLoading, isFetching: rankFetching, refetch: refetchRanks } =
|
||||
useGetRanksQuery();
|
||||
|
||||
const departments = deptRes?.items ?? [];
|
||||
const ranks = rankRes?.items ?? [];
|
||||
|
||||
const deptOptions = departments.map((d) => ({ value: d.id, label: localized(d.name) }));
|
||||
const deptName = useCallback(
|
||||
(id: string) => departments.find((d) => d.id === id)?.code ?? "-",
|
||||
[departments],
|
||||
);
|
||||
|
||||
if (deptLoading || rankLoading) {
|
||||
return <PageLoader label={t("configuration.loadingRanks", "Loading departments and ranks…")} height={300} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"configuration.rankLadderNotice",
|
||||
"A rank's position is its rung on the ladder — an applicant is auto-advanced to the next position up from what they already hold.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<DepartmentSection
|
||||
departments={departments}
|
||||
isFetching={deptFetching}
|
||||
refetch={refetchDepts}
|
||||
localized={localized}
|
||||
/>
|
||||
|
||||
<RankSection
|
||||
ranks={ranks}
|
||||
deptOptions={deptOptions}
|
||||
deptName={deptName}
|
||||
isFetching={rankFetching}
|
||||
refetch={refetchRanks}
|
||||
localized={localized}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- departments
|
||||
|
||||
function DepartmentSection({
|
||||
departments,
|
||||
isFetching,
|
||||
refetch,
|
||||
localized,
|
||||
}: {
|
||||
departments: Department[];
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
localized: (v: Department["name"]) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const [createDepartment, { isLoading: isCreating }] = useCreateDepartmentMutation();
|
||||
const [updateDepartment, { isLoading: isUpdating }] = useUpdateDepartmentMutation();
|
||||
const [deleteDepartment] = useDeleteDepartmentMutation();
|
||||
|
||||
const [editing, setEditing] = useState<Department | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Department | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setEditing(null);
|
||||
setShowForm(false);
|
||||
}, []);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: { code: "", nameEn: "", nameAm: "", sortOrder: 0 },
|
||||
validate: {
|
||||
code: (v) => (!v ? t("configuration.validation.codeRequired", "Code is required") : null),
|
||||
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
|
||||
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = useCallback(
|
||||
(dept: Department) => {
|
||||
setEditing(dept);
|
||||
form.setValues({
|
||||
code: dept.code,
|
||||
nameEn: dept.name.en ?? "",
|
||||
nameAm: dept.name.am ?? "",
|
||||
sortOrder: dept.sortOrder,
|
||||
});
|
||||
setShowForm(true);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
try {
|
||||
if (editing) {
|
||||
await updateDepartment({
|
||||
id: editing.id,
|
||||
code: values.code,
|
||||
name,
|
||||
sortOrder: values.sortOrder,
|
||||
}).unwrap();
|
||||
notify.success(t("configuration.updated"));
|
||||
} else {
|
||||
await createDepartment({ code: values.code, name, sortOrder: values.sortOrder }).unwrap();
|
||||
notify.success(t("configuration.created"));
|
||||
}
|
||||
resetForm();
|
||||
form.reset();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
});
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteDepartment(deleteTarget.id).unwrap();
|
||||
notify.success(t("configuration.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [deleteTarget, deleteDepartment, closeDelete, handleError]);
|
||||
|
||||
const columns: AdvancedColumn<Department>[] = [
|
||||
{ header: t("configuration.code", "Code"), cell: ({ row }) => row.original.code },
|
||||
{ header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) },
|
||||
{ header: t("configuration.sortOrder", "Order"), cell: ({ row }) => row.original.sortOrder },
|
||||
{
|
||||
header: "actions",
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)}>
|
||||
{t("configuration.edit", "Edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setDeleteTarget(row.original);
|
||||
openDelete();
|
||||
}}
|
||||
>
|
||||
{t("configuration.delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Title order={4}>{t("configuration.departments", "Departments")}</Title>
|
||||
{!showForm && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
{t("configuration.addDepartment", "Add department")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={[...departments].sort((a, b) => a.sortOrder - b.sortOrder)}
|
||||
tableName={t("configuration.departments", "Departments")}
|
||||
itemCount={departments.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
pageSize={departments.length || 10}
|
||||
onPageSizeChange={() => {}}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={showForm}
|
||||
onClose={resetForm}
|
||||
title={editing ? t("configuration.update") : t("configuration.addDepartment", "Add department")}
|
||||
size="sm"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("configuration.code", "Code")}
|
||||
placeholder="DECK"
|
||||
{...form.getInputProps("code")}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput label={t("configuration.nameEn")} {...form.getInputProps("nameEn")} size="sm" />
|
||||
<TextInput label={t("configuration.nameAm")} {...form.getInputProps("nameAm")} size="sm" />
|
||||
<NumberInput
|
||||
label={t("configuration.sortOrder", "Sort order")}
|
||||
{...form.getInputProps("sortOrder")}
|
||||
size="sm"
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={resetForm} size="sm">
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
|
||||
{editing ? t("configuration.update") : t("configuration.create")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t("configuration.confirmDelete")} size="sm">
|
||||
<Text mb="md">
|
||||
{t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={confirmDelete} size="sm">
|
||||
{t("configuration.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ranks
|
||||
|
||||
function RankSection({
|
||||
ranks,
|
||||
deptOptions,
|
||||
deptName,
|
||||
isFetching,
|
||||
refetch,
|
||||
localized,
|
||||
}: {
|
||||
ranks: Rank[];
|
||||
deptOptions: { value: string; label: string }[];
|
||||
deptName: (id: string) => string;
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
localized: (v: Rank["name"]) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const [createRank, { isLoading: isCreating }] = useCreateRankMutation();
|
||||
const [updateRank, { isLoading: isUpdating }] = useUpdateRankMutation();
|
||||
const [deleteRank] = useDeleteRankMutation();
|
||||
|
||||
const [editing, setEditing] = useState<Rank | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Rank | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setEditing(null);
|
||||
setShowForm(false);
|
||||
}, []);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
departmentId: "",
|
||||
certificateCategory: "COC" as RankCertificateCategory,
|
||||
key: "",
|
||||
nameEn: "",
|
||||
nameAm: "",
|
||||
ladderOrder: 0,
|
||||
},
|
||||
validate: {
|
||||
departmentId: (v) => (!v ? t("configuration.validation.departmentRequired") : null),
|
||||
key: (v) => (!v ? t("configuration.validation.keyRequired", "Key is required") : null),
|
||||
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
|
||||
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = useCallback(
|
||||
(rank: Rank) => {
|
||||
setEditing(rank);
|
||||
form.setValues({
|
||||
departmentId: rank.departmentId,
|
||||
certificateCategory: rank.certificateCategory,
|
||||
key: rank.key,
|
||||
nameEn: rank.name.en ?? "",
|
||||
nameAm: rank.name.am ?? "",
|
||||
ladderOrder: rank.ladderOrder,
|
||||
});
|
||||
setShowForm(true);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
try {
|
||||
if (editing) {
|
||||
await updateRank({
|
||||
id: editing.id,
|
||||
departmentId: values.departmentId,
|
||||
certificateCategory: values.certificateCategory,
|
||||
key: values.key,
|
||||
name,
|
||||
ladderOrder: values.ladderOrder,
|
||||
}).unwrap();
|
||||
notify.success(t("configuration.updated"));
|
||||
} else {
|
||||
await createRank({
|
||||
departmentId: values.departmentId,
|
||||
certificateCategory: values.certificateCategory,
|
||||
key: values.key,
|
||||
name,
|
||||
ladderOrder: values.ladderOrder,
|
||||
}).unwrap();
|
||||
notify.success(t("configuration.created"));
|
||||
}
|
||||
resetForm();
|
||||
form.reset();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
});
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteRank(deleteTarget.id).unwrap();
|
||||
notify.success(t("configuration.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [deleteTarget, deleteRank, closeDelete, handleError]);
|
||||
|
||||
const sortedRanks = [...ranks].sort(
|
||||
(a, b) =>
|
||||
a.departmentId.localeCompare(b.departmentId) ||
|
||||
a.certificateCategory.localeCompare(b.certificateCategory) ||
|
||||
a.ladderOrder - b.ladderOrder,
|
||||
);
|
||||
|
||||
const columns: AdvancedColumn<Rank>[] = [
|
||||
{ header: t("configuration.department"), cell: ({ row }) => deptName(row.original.departmentId) },
|
||||
{ header: t("configuration.category", "Ladder"), cell: ({ row }) => row.original.certificateCategory },
|
||||
{ header: t("configuration.rankOrder", "Rung"), cell: ({ row }) => row.original.ladderOrder },
|
||||
{ header: t("configuration.key", "Key"), cell: ({ row }) => row.original.key },
|
||||
{ header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) },
|
||||
{
|
||||
header: "actions",
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)}>
|
||||
{t("configuration.edit", "Edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setDeleteTarget(row.original);
|
||||
openDelete();
|
||||
}}
|
||||
>
|
||||
{t("configuration.delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Title order={4}>{t("configuration.ranks", "Ranks")}</Title>
|
||||
{!showForm && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setShowForm(true);
|
||||
}}
|
||||
disabled={deptOptions.length === 0}
|
||||
>
|
||||
{t("configuration.addRank", "Add rank")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={sortedRanks}
|
||||
tableName={t("configuration.ranks", "Ranks")}
|
||||
itemCount={sortedRanks.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
pageSize={sortedRanks.length || 10}
|
||||
onPageSizeChange={() => {}}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={showForm}
|
||||
onClose={resetForm}
|
||||
title={editing ? t("configuration.update") : t("configuration.addRank", "Add rank")}
|
||||
size="sm"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("configuration.department")}
|
||||
data={deptOptions}
|
||||
{...form.getInputProps("departmentId")}
|
||||
size="sm"
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label={t("configuration.category", "Ladder")}
|
||||
data={CATEGORY_OPTIONS}
|
||||
{...form.getInputProps("certificateCategory")}
|
||||
size="sm"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("configuration.key", "Key")}
|
||||
placeholder="CHIEF_MATE"
|
||||
{...form.getInputProps("key")}
|
||||
size="sm"
|
||||
disabled={!!editing}
|
||||
description={
|
||||
editing
|
||||
? t(
|
||||
"configuration.keyLockedNotice",
|
||||
"Not editable — issued licences already carry this key.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextInput label={t("configuration.nameEn")} {...form.getInputProps("nameEn")} size="sm" />
|
||||
<TextInput label={t("configuration.nameAm")} {...form.getInputProps("nameAm")} size="sm" />
|
||||
<NumberInput
|
||||
label={t("configuration.rankOrder", "Rung (0 = entry rank)")}
|
||||
min={0}
|
||||
{...form.getInputProps("ladderOrder")}
|
||||
size="sm"
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={resetForm} size="sm">
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
|
||||
{editing ? t("configuration.update") : t("configuration.create")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t("configuration.confirmDelete")} size="sm">
|
||||
<Text mb="md">
|
||||
{t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={confirmDelete} size="sm">
|
||||
{t("configuration.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
IconCertificate,
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
IconAnchor,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import { LocationPage } from "../../../location/pages/LocationPage";
|
||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
||||
import { RankDepartmentTab } from "./RankDepartmentTab";
|
||||
import {
|
||||
useGetOrganizationsQuery,
|
||||
useGetProfessionsQuery,
|
||||
@@ -401,6 +403,9 @@ export function ConfigurationPage() {
|
||||
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
|
||||
{t("numberFormat.title", "Number Formats")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
|
||||
{t("configuration.ranksTab", "Ranks & Departments")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="professions" pt="md">
|
||||
@@ -418,6 +423,10 @@ export function ConfigurationPage() {
|
||||
<Tabs.Panel value="numberFormats" pt="md">
|
||||
<NumberFormatTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="ranks" pt="md">
|
||||
<RankDepartmentTab />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ExamIncident,
|
||||
CreateIncidentPayload,
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
getExam: builder.query<Exam, string>({
|
||||
query: (id) => `/exams/${id}?i=questions`,
|
||||
// Nested relation so CHOICE questions carry their options here too —
|
||||
// needed to print real answer choices instead of blank A/B/C/D lines.
|
||||
query: (id) => `/exams/${id}?i=questions,questions.options`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createExam: builder.mutation<Exam, CreateExamPayload>({
|
||||
@@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Staff-triggered re-run of auto-grading for one finalized attempt. */
|
||||
regradeAttempt: builder.mutation<RegradeOutcome, string>({
|
||||
query: (attemptId) => ({
|
||||
url: `/exam-attempts/${attemptId}/regrade`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -107,4 +118,5 @@ export const {
|
||||
useGetExamIncidentsQuery,
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
useRegradeAttemptMutation,
|
||||
} = examApi;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconUserCheck } from '@tabler/icons-react';
|
||||
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
|
||||
import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
|
||||
|
||||
export function examCandidateColumns(
|
||||
t: TFunction,
|
||||
handlers: { onRecord: (registration: ExamRegistration) => void },
|
||||
handlers: {
|
||||
onRecord: (registration: ExamRegistration) => void;
|
||||
onRegrade: (registration: ExamRegistration) => void;
|
||||
regrading?: string | null;
|
||||
},
|
||||
): AdvancedColumn<ExamRegistration>[] {
|
||||
return [
|
||||
{
|
||||
@@ -78,21 +82,51 @@ export function examCandidateColumns(
|
||||
header: '',
|
||||
label: t('exam.candidates.record'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconUserCheck size={12} />}
|
||||
onClick={() => handlers.onRecord(row.original)}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
|
||||
return (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
loading={handlers.regrading === row.original.attempt?.id}
|
||||
>
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
|
||||
hideOnly
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCheck size={14} />}
|
||||
onClick={() => handlers.onRecord(row.original)}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
{canRegrade && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]}
|
||||
hideOnly
|
||||
>
|
||||
<Menu.Item
|
||||
color="grape"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => handlers.onRegrade(row.original)}
|
||||
>
|
||||
{t('exam.candidates.regrade')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
useRegradeAttemptMutation,
|
||||
} from '../../api/exam-api';
|
||||
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
||||
import { candidateName, examCandidateColumns } from './columns';
|
||||
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
|
||||
const [regradeAttempt] = useRegradeAttemptMutation();
|
||||
const [regrading, setRegrading] = useState<string | null>(null);
|
||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||
const [remark, setRemark] = useState('');
|
||||
const table = useServerTable();
|
||||
|
||||
const regrade = async (registration: ExamRegistration) => {
|
||||
const attemptId = registration.attempt?.id;
|
||||
if (!attemptId) return;
|
||||
setRegrading(attemptId);
|
||||
try {
|
||||
const outcome = await regradeAttempt(attemptId).unwrap();
|
||||
if (outcome.graded) {
|
||||
notify.success(t('exam.candidates.regraded'));
|
||||
} else {
|
||||
notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason }));
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.candidates.regradeError')));
|
||||
} finally {
|
||||
setRegrading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = (registration: ExamRegistration) => {
|
||||
setTarget(registration);
|
||||
setStatus(
|
||||
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
) : (
|
||||
<AdvancedTable
|
||||
tableName={t('exam.candidates.section')}
|
||||
columns={examCandidateColumns(t, { onRecord: startRecording })}
|
||||
columns={examCandidateColumns(t, {
|
||||
onRecord: startRecording,
|
||||
onRegrade: regrade,
|
||||
regrading,
|
||||
})}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
|
||||
@@ -67,7 +67,11 @@ const STATUS_TONE: Record<string, StatusTone> = {
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
|
||||
const FORM_LABEL: Record<string, string> = {
|
||||
ESSAY: "Essay",
|
||||
CHOICE: "Choice",
|
||||
BOTH: "Both",
|
||||
};
|
||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
|
||||
const ADMIN_LABEL: Record<string, string> = {
|
||||
OFFLINE: "Offline",
|
||||
@@ -117,13 +121,18 @@ 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
|
||||
// QuestionForm), so an equality check against it would match nothing and
|
||||
// silently offer zero questions. Same skip-condition as the backend's own
|
||||
// random draw (ExamService.selectRandomQuestions).
|
||||
const eligibleQuestions = useMemo(() => {
|
||||
if (!exam) return [];
|
||||
return allQuestions
|
||||
.filter(
|
||||
(q) =>
|
||||
q.certificationId === exam.certificationId &&
|
||||
q.form === exam.form &&
|
||||
(exam.form === 'BOTH' || q.form === exam.form) &&
|
||||
q.status === 'APPROVED',
|
||||
)
|
||||
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
|
||||
@@ -172,7 +181,12 @@ export function ExamDetailPage() {
|
||||
notify.error(
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key,
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
max: key.split(':')[1]?.split('/')[0] ?? '',
|
||||
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
|
||||
})
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -188,18 +202,35 @@ export function ExamDetailPage() {
|
||||
notify.error(
|
||||
key.startsWith('question_not_approved')
|
||||
? t('question.qc.onlyApprovedUsable')
|
||||
: key,
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
max: key.split(':')[1]?.split('/')[0] ?? '',
|
||||
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
|
||||
})
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handlePrint = async () => {
|
||||
const total = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
if (total < Number(exam.cuttingPoint)) {
|
||||
// The reachable max depends on the evaluation method, not the raw point
|
||||
// sum — mirrors RecordResultModal's grading math so "can this paper pass"
|
||||
// means the same thing here as it does at marking time. Cutting point can
|
||||
// be raised after the paper was assembled (edit modal, no re-check on
|
||||
// save), so this still needs to run even though assignment now enforces
|
||||
// it too.
|
||||
const questions = exam.questions ?? [];
|
||||
const total = questions.reduce((s, q) => s + Number(q.points), 0);
|
||||
const reachableMax =
|
||||
exam.evaluationMethod === 'AVERAGE'
|
||||
? questions.length
|
||||
? total / questions.length
|
||||
: 0
|
||||
: exam.evaluationMethod === 'PERCENTAGE'
|
||||
? 100
|
||||
: total;
|
||||
if (reachableMax < Number(exam.cuttingPoint)) {
|
||||
notify.error(
|
||||
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||
`This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass mark ${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +264,23 @@ export function ExamDetailPage() {
|
||||
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
|
||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
|
||||
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
|
||||
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
|
||||
${
|
||||
q.form === "CHOICE"
|
||||
? q.options && q.options.length
|
||||
? q.options
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(
|
||||
(o, oi) =>
|
||||
`<p style="margin: 4px 0; font-size: 13px;">${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}</p>`,
|
||||
)
|
||||
.join("")
|
||||
// No options on record (legacy question, or options relation
|
||||
// wasn't loaded) — fall back to blank lines rather than
|
||||
// printing nothing.
|
||||
: ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("")
|
||||
: ""
|
||||
}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -248,7 +295,20 @@ export function ExamDetailPage() {
|
||||
.header p { margin: 2px 0; font-size: 13px; color: #555; }
|
||||
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
|
||||
.directions strong { display: block; margin-bottom: 4px; }
|
||||
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
||||
.footer { margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center; }
|
||||
/* Pinned to the bottom of every printed page (not just after the
|
||||
last question) — @page's bottom margin leaves room for it so it
|
||||
never overlaps question text on the last page. */
|
||||
@media print {
|
||||
@page { margin: 20mm 20mm 28mm 20mm; }
|
||||
/* @page's margin already insets content from the physical page
|
||||
edge — body's own 40px padding (needed on-screen, for the
|
||||
preview tab before printing) would double up with it here,
|
||||
wasting real page height on every side and fitting noticeably
|
||||
fewer questions per page than the paper actually has room for. */
|
||||
body { -webkit-print-color-adjust: exact; padding: 0; max-width: none; }
|
||||
.footer { position: fixed; bottom: 0; left: 0; right: 0; margin-top: 0; }
|
||||
}
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
|
||||
@@ -259,7 +319,7 @@ export function ExamDetailPage() {
|
||||
</div>
|
||||
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
|
||||
${qHtml}
|
||||
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
|
||||
<div class="footer">
|
||||
Generated by EMA — Ethiopian Maritime Authority
|
||||
</div>
|
||||
</body></html>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ActionIcon, Group } from "@mantine/core";
|
||||
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconDetails,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconToggleRight,
|
||||
} from "@tabler/icons-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
@@ -11,40 +17,55 @@ export function examActionsColumn(
|
||||
onEdit: (exam: Exam) => void;
|
||||
onDelete: (exam: Exam) => void;
|
||||
onDetails: (exam: Exam) => void;
|
||||
onOpenStatusChange: (exam: Exam) => void;
|
||||
changingStatusId?: string | null;
|
||||
},
|
||||
): AdvancedColumn<Exam> {
|
||||
return {
|
||||
header: t("exam.columns.actions"),
|
||||
header: t("exam.columns.actions", "Actions"),
|
||||
align: "right",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
loading={handlers.changingStatusId === row.original.id}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconDetails size={14} />}
|
||||
onClick={() => handlers.onDetails(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</RequirePermission>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDetails(row.original)}
|
||||
>
|
||||
<IconDetails size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{t("exam.action.details", "Details")}
|
||||
</Menu.Item>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
{t("exam.action.edit", "Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconToggleRight size={14} />}
|
||||
onClick={() => handlers.onOpenStatusChange(row.original)}
|
||||
>
|
||||
{t("exam.form.status")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
{t("exam.action.delete", "Delete")}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +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 { useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
useCreateExamMutation,
|
||||
@@ -76,21 +77,23 @@ function ExamForm({
|
||||
editing?.cuttingPoint ?? 0,
|
||||
);
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!certificationId ||
|
||||
!titleEn ||
|
||||
!titleAm ||
|
||||
!date ||
|
||||
!type ||
|
||||
!form ||
|
||||
!venue ||
|
||||
!adminMethod ||
|
||||
!evalMethod
|
||||
) {
|
||||
notify.error("Please fill all required fields");
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.fillRequiredBasic"));
|
||||
return;
|
||||
}
|
||||
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.directionBothLanguages"));
|
||||
return;
|
||||
}
|
||||
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
||||
setActiveTab("settings");
|
||||
notify.error(t("exam.form.fillRequiredSettings"));
|
||||
return;
|
||||
}
|
||||
onSubmit(
|
||||
@@ -120,7 +123,7 @@ function ExamForm({
|
||||
return (
|
||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs value={activeTab} onChange={setActiveTab} variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||
{t("exam.form.basicInfo")}
|
||||
@@ -246,11 +249,18 @@ function ExamForm({
|
||||
data={[
|
||||
{ value: "ESSAY", label: t("exam.form.essay") },
|
||||
{ value: "CHOICE", label: t("exam.form.choice") },
|
||||
{ value: "BOTH", label: t("exam.form.both") },
|
||||
]}
|
||||
value={form}
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
disabled={adminMethod === "ONLINE"}
|
||||
description={
|
||||
adminMethod === "ONLINE"
|
||||
? t("exam.form.onlineChoiceOnlyHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
@@ -260,7 +270,14 @@ function ExamForm({
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={setAdminMethod}
|
||||
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");
|
||||
}}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
@@ -290,12 +307,22 @@ function ExamForm({
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.cuttingPoint")}
|
||||
placeholder={t("exam.form.cuttingPointPlaceholder")}
|
||||
placeholder={
|
||||
evalMethod === "PERCENTAGE"
|
||||
? t("exam.form.cuttingPointPercentagePlaceholder")
|
||||
: t("exam.form.cuttingPointPlaceholder")
|
||||
}
|
||||
value={cuttingPoint}
|
||||
onChange={(v) => setCuttingPoint(Number(v))}
|
||||
min={0}
|
||||
max={evalMethod === "PERCENTAGE" ? 100 : undefined}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
description={
|
||||
evalMethod === "PERCENTAGE"
|
||||
? t("exam.form.cuttingPointPercentageHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
@@ -337,7 +364,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();
|
||||
@@ -352,10 +382,24 @@ export function ExamPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
const [changingStatusId, setChangingStatusId] = useState<string | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<Exam | null>(null);
|
||||
const [pendingStatus, setPendingStatus] = useState<Exam["status"] | null>(null);
|
||||
const [statusOpened, { open: openStatus, close: closeStatus }] =
|
||||
useDisclosure(false);
|
||||
|
||||
// Rank in the label: an exam inherits its STCW rank from the certification
|
||||
// it is created under (Certification.rankKey), so the officer sees which
|
||||
// rank a sitting will serve at the moment they pick the subject.
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
.map((c) => {
|
||||
const rank = c.rankKey ? rankLabelByKey.get(c.rankKey) : undefined;
|
||||
return {
|
||||
value: c.id,
|
||||
label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale],
|
||||
};
|
||||
});
|
||||
const getCertName = (id: string) =>
|
||||
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||
|
||||
@@ -402,6 +446,21 @@ export function ExamPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeStatus = async () => {
|
||||
if (!statusTarget || !pendingStatus) return;
|
||||
setChangingStatusId(statusTarget.id);
|
||||
try {
|
||||
await updateExam({ id: statusTarget.id, status: pendingStatus }).unwrap();
|
||||
notify.success(t("exam.updated"));
|
||||
closeStatus();
|
||||
setStatusTarget(null);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setChangingStatusId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
@@ -429,6 +488,12 @@ export function ExamPage() {
|
||||
openDelete();
|
||||
},
|
||||
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
||||
onOpenStatusChange: (exam) => {
|
||||
setStatusTarget(exam);
|
||||
setPendingStatus(exam.status);
|
||||
openStatus();
|
||||
},
|
||||
changingStatusId,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -503,6 +568,47 @@ export function ExamPage() {
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
{/* Quick status change — not the full edit form */}
|
||||
<Modal
|
||||
opened={statusOpened}
|
||||
onClose={closeStatus}
|
||||
title={t("exam.form.status")}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{statusTarget?.title?.[locale]}
|
||||
</Text>
|
||||
<Select
|
||||
label={t("exam.form.status")}
|
||||
data={[
|
||||
{ value: "PENDING", label: t("exam.form.pending") },
|
||||
{ value: "ACTIVE", label: t("exam.form.active") },
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={pendingStatus}
|
||||
onChange={(value) => setPendingStatus(value as Exam["status"])}
|
||||
size="sm"
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeStatus} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleChangeStatus}
|
||||
size="sm"
|
||||
loading={changingStatusId === statusTarget?.id}
|
||||
disabled={pendingStatus === statusTarget?.status}
|
||||
>
|
||||
{t("exam.update")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,33 @@ import type { EstimatedTime } from "../../question/types/question";
|
||||
import type { QuestionForm } from "../../question/types/question";
|
||||
export type { QuestionForm };
|
||||
|
||||
/**
|
||||
* The paper's own form — unlike a question's (QuestionForm), an exam may mix
|
||||
* both: BOTH means a mixed paper, resolved per-question against each
|
||||
* question's own ESSAY/CHOICE form. Mirrors backend EExamForm.
|
||||
*/
|
||||
export type ExamForm = QuestionForm | "BOTH";
|
||||
|
||||
export type ExamType = "WRITTEN" | "ORAL";
|
||||
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
||||
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
||||
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
|
||||
export type ExamStatus =
|
||||
| "PENDING"
|
||||
| "ACTIVE"
|
||||
| "COMPLETED"
|
||||
| "CANCELLED"
|
||||
| "POSTPONED"
|
||||
| "PUBLISHED";
|
||||
"PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED" | "PUBLISHED";
|
||||
|
||||
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
||||
export interface QuestionOptionBrief {
|
||||
id: string;
|
||||
text: LocalePair;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface QuestionBrief {
|
||||
id: string;
|
||||
title: LocalePair;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options?: QuestionOptionBrief[];
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
@@ -31,7 +41,7 @@ export interface Exam {
|
||||
date: string;
|
||||
givenTime: EstimatedTime | null;
|
||||
type: ExamType;
|
||||
form: QuestionForm;
|
||||
form: ExamForm;
|
||||
venue: string;
|
||||
administrationMethod: ExamAdministrationMethod;
|
||||
evaluationMethod: ExamEvaluationMethod;
|
||||
@@ -55,7 +65,7 @@ export interface CreateExamPayload {
|
||||
date: string;
|
||||
givenTime: EstimatedTime;
|
||||
type: ExamType;
|
||||
form: QuestionForm;
|
||||
form: ExamForm;
|
||||
venue: string;
|
||||
administrationMethod: ExamAdministrationMethod;
|
||||
evaluationMethod: ExamEvaluationMethod;
|
||||
@@ -71,7 +81,7 @@ export interface UpdateExamPayload {
|
||||
date?: string;
|
||||
givenTime?: EstimatedTime;
|
||||
type?: ExamType;
|
||||
form?: QuestionForm;
|
||||
form?: ExamForm;
|
||||
venue?: string;
|
||||
administrationMethod?: ExamAdministrationMethod;
|
||||
evaluationMethod?: ExamEvaluationMethod;
|
||||
@@ -93,19 +103,14 @@ export interface RandomQuestionsPayload {
|
||||
|
||||
/** What the invigilator recorded on the day (US-EXAM-009). */
|
||||
export type AttendanceStatus =
|
||||
| 'REGISTERED'
|
||||
| 'PRESENT'
|
||||
| 'ABSENT'
|
||||
| 'LATE'
|
||||
| 'WITHDRAWN'
|
||||
| 'DISQUALIFIED';
|
||||
"REGISTERED" | "PRESENT" | "ABSENT" | "LATE" | "WITHDRAWN" | "DISQUALIFIED";
|
||||
|
||||
export interface ExamRegistration {
|
||||
id: string;
|
||||
examId: string;
|
||||
profileId: string;
|
||||
admissionNumber: string;
|
||||
kind: 'NEW' | 'RETAKE';
|
||||
kind: "NEW" | "RETAKE";
|
||||
attemptNumber: number;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
attendanceRemark: string | null;
|
||||
@@ -118,8 +123,16 @@ export interface ExamRegistration {
|
||||
lastName: string | null;
|
||||
seafarerNumber: string | null;
|
||||
};
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: {
|
||||
id: string;
|
||||
status: "IN_PROGRESS" | "SUBMITTED" | "EXPIRED";
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type RegradeOutcome =
|
||||
{ graded: true; resultId: string } | { graded: false; reason: string };
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
@@ -127,17 +140,10 @@ export interface RecordAttendancePayload {
|
||||
}
|
||||
|
||||
export type ExamIncidentType =
|
||||
| 'MISCONDUCT'
|
||||
| 'TECHNICAL_FAILURE'
|
||||
| 'MEDICAL'
|
||||
| 'ADMINISTRATIVE'
|
||||
| 'OTHER';
|
||||
"MISCONDUCT" | "TECHNICAL_FAILURE" | "MEDICAL" | "ADMINISTRATIVE" | "OTHER";
|
||||
|
||||
export type ExamIncidentStatus =
|
||||
| 'OPEN'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'RESOLVED'
|
||||
| 'DISMISSED';
|
||||
"OPEN" | "UNDER_REVIEW" | "RESOLVED" | "DISMISSED";
|
||||
|
||||
export interface ExamIncident {
|
||||
id: string;
|
||||
@@ -162,6 +168,6 @@ export interface CreateIncidentPayload {
|
||||
|
||||
export interface ResolveIncidentPayload {
|
||||
incidentId: string;
|
||||
outcome: 'RESOLVED' | 'DISMISSED';
|
||||
outcome: "RESOLVED" | "DISMISSED";
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
import { useGetEligibleExamsQuery } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
applicationId: string;
|
||||
applicantName: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (payload: {
|
||||
examId: string;
|
||||
admissionNumber?: string;
|
||||
examDate?: string;
|
||||
}) => void;
|
||||
onConfirm: (payload: { examId: string; examDate?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,33 +19,34 @@ interface Props {
|
||||
*
|
||||
* Sessions are picked from the exam calendar rather than typed, because the
|
||||
* candidate joins a scheduled sitting — this is an assignment, not the creation
|
||||
* of a per-candidate appointment.
|
||||
* 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.
|
||||
*/
|
||||
export function ScheduleExamModal({
|
||||
opened,
|
||||
applicationId,
|
||||
applicantName,
|
||||
loading,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened });
|
||||
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
|
||||
const [examId, setExamId] = useState<string | null>(null);
|
||||
const [admissionNumber, setAdmissionNumber] = useState('');
|
||||
|
||||
const options = (exams?.items ?? []).map((exam) => ({
|
||||
const options = (exams ?? []).map((exam) => ({
|
||||
value: exam.id,
|
||||
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
}));
|
||||
const selected = exams?.items?.find((exam) => exam.id === examId);
|
||||
const selected = exams?.find((exam) => exam.id === examId);
|
||||
|
||||
function confirm() {
|
||||
if (!examId) return;
|
||||
onConfirm({
|
||||
examId,
|
||||
admissionNumber: admissionNumber.trim() || undefined,
|
||||
examDate: selected?.date ? String(selected.date) : undefined,
|
||||
});
|
||||
}
|
||||
@@ -72,7 +70,7 @@ export function ScheduleExamModal({
|
||||
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
|
||||
{t(
|
||||
'review.scheduleExam.noSessions',
|
||||
'No exam sessions exist yet. Create one in the Exams area first.',
|
||||
'No exam sessions for this rank exist yet. Create one in the Exams area first.',
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -88,15 +86,12 @@ export function ScheduleExamModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('review.scheduleExam.admissionNumber', 'Admission number')}
|
||||
description={t(
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'review.scheduleExam.admissionHint',
|
||||
'Leave blank to let the system issue one.',
|
||||
'An admission number is issued automatically when the candidate is seated.',
|
||||
)}
|
||||
value={admissionNumber}
|
||||
onChange={(e) => setAdmissionNumber(e.currentTarget.value)}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
|
||||
@@ -119,6 +119,9 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
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.
|
||||
{
|
||||
id: 'assign',
|
||||
tier: 'workflow',
|
||||
@@ -129,6 +132,7 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'INSPECTION_FAILED',
|
||||
'ON_HOLD',
|
||||
],
|
||||
permissions: ['can:assign:license-application'],
|
||||
@@ -152,6 +156,7 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'INSPECTION_FAILED',
|
||||
],
|
||||
permissions: ['can:hold:license-application'],
|
||||
emphasis: 'subtle',
|
||||
@@ -190,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',
|
||||
},
|
||||
@@ -198,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',
|
||||
},
|
||||
@@ -209,6 +215,9 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
from: ['INSPECTION_COMPLETED',
|
||||
'REVIEW_REPORTED',
|
||||
'INSPECTION_REPORTED', 'UNDER_EVALUATION'],
|
||||
// ELIGIBILITY_PAID: an examined certificate (CoC/CoP) is decided straight
|
||||
// off the eligibility queue — no assignment step.
|
||||
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION', 'ELIGIBILITY_PAID'],
|
||||
permissions: ['can:approve:license-application'],
|
||||
emphasis: 'filled',
|
||||
color: 'teal',
|
||||
@@ -221,6 +230,13 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED',
|
||||
'REVIEW_REPORTED',
|
||||
'INSPECTION_REPORTED'],
|
||||
from: [
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_COMPLETED',
|
||||
'INSPECTION_FAILED',
|
||||
'ELIGIBILITY_PAID',
|
||||
],
|
||||
permissions: ['can:request-adjustment:license-application'],
|
||||
emphasis: 'light',
|
||||
color: 'orange',
|
||||
@@ -237,6 +253,8 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
'INSPECTION_COMPLETED',
|
||||
'REVIEW_REPORTED',
|
||||
'INSPECTION_REPORTED',
|
||||
'INSPECTION_FAILED',
|
||||
'ELIGIBILITY_PAID',
|
||||
],
|
||||
permissions: ['can:reject:license-application'],
|
||||
emphasis: 'light',
|
||||
@@ -336,11 +354,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
|
||||
@@ -415,12 +440,30 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
|
||||
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
|
||||
|
||||
// The transition table doesn't know which types need an inspection, so
|
||||
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for
|
||||
// types without one — where the server refuses it
|
||||
// (`inspection_not_required_use_final_approve`). Final Approve is the
|
||||
// real action there; don't render its dead twin.
|
||||
if (
|
||||
action.id === 'approve-documents' &&
|
||||
!app.licenseType?.inspectionRequired
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const disabled = (reason: string): ResolvedAction => ({
|
||||
...action,
|
||||
enabled: false,
|
||||
disabledReason: reason,
|
||||
});
|
||||
|
||||
// Booked but not yet due: keep the button visible so the officer sees
|
||||
// the next step, disabled with the scheduled time as the reason.
|
||||
if (action.id === 'record-inspection' && ctx.inspectionNotYetDue) {
|
||||
return disabled(reasons.inspectionNotYetDue);
|
||||
}
|
||||
|
||||
// Decisions belong to whoever holds the application.
|
||||
const needsOwnership =
|
||||
action.tier === 'primary' && action.id !== 'confirm-payment';
|
||||
|
||||
@@ -116,11 +116,21 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
// no capital threshold, no staff roles.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
ENDORSEMENT_SEAFARER: {
|
||||
key: 'ENDORSEMENT_SEAFARER',
|
||||
icon: IconRubberStamp,
|
||||
// Person-centric, same as seafarer registration: no company entity, no
|
||||
// capital threshold, no staff roles, no inspection. Covers CoC and GOC
|
||||
// together — the `endorsementScope` field on the application decides
|
||||
// which certificate section(s) actually have data.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
// Retired by ENDORSEMENT_SEAFARER (see endorsements.seed-data.ts). Kept so
|
||||
// an application filed before the switch still renders with the right
|
||||
// presentation instead of falling back to the generic company layout.
|
||||
ENDORSEMENT_COC: {
|
||||
key: 'ENDORSEMENT_COC',
|
||||
icon: IconRubberStamp,
|
||||
// Person-centric, same as seafarer registration: no company entity, no
|
||||
// capital threshold, no staff roles, no inspection.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
ENDORSEMENT_GOC: {
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
SAVED_VIEWS,
|
||||
filterFromSearchParams,
|
||||
readLastView,
|
||||
hasUnclaimedPool,
|
||||
savedViewsForFamily,
|
||||
searchParamsFromFilter,
|
||||
writeLastView,
|
||||
@@ -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),
|
||||
@@ -403,6 +404,10 @@ export function LicenseQueuePage() {
|
||||
// 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([]),
|
||||
onHelp: () => setHelpOpen(true),
|
||||
@@ -469,6 +474,9 @@ export function LicenseQueuePage() {
|
||||
// Non-logistics applications aren't dispatched off a shared queue (see
|
||||
// `savedViewsForFamily`) — every row opens straight to Review.
|
||||
assignable: isLogistics !== false,
|
||||
// Applications with no unclaimed pool (see `hasUnclaimedPool`) are
|
||||
// never claimed — every row opens straight to Review.
|
||||
claimable,
|
||||
}),
|
||||
],
|
||||
[
|
||||
@@ -481,6 +489,7 @@ export function LicenseQueuePage() {
|
||||
items,
|
||||
assigning,
|
||||
isLogistics,
|
||||
claimable,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -748,6 +757,19 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
{claimable && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t("queue.bulkClaim", {
|
||||
count: selected.length,
|
||||
defaultValue: "Claim {{count}}",
|
||||
})}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
useAssignInspectorMutation,
|
||||
useReportReviewMutation,
|
||||
useReportInspectionMutation,
|
||||
useClaimApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
@@ -210,6 +211,7 @@ export function LicenseReviewPage() {
|
||||
[requirements],
|
||||
);
|
||||
|
||||
const [claimApplication] = useClaimApplicationMutation();
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
const [approveDocuments] = useApproveDocumentsMutation();
|
||||
@@ -308,6 +310,12 @@ 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(
|
||||
@@ -370,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'),
|
||||
@@ -378,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(
|
||||
@@ -392,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
|
||||
@@ -596,8 +612,12 @@ export function LicenseReviewPage() {
|
||||
try {
|
||||
switch (action.id) {
|
||||
case "claim":
|
||||
// Claim is fired from the queue in practice; kept here for the case
|
||||
// where an officer opens an unclaimed application directly.
|
||||
// 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", "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.
|
||||
@@ -1194,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">
|
||||
@@ -1301,6 +1333,7 @@ export function LicenseReviewPage() {
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicationId={id}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
@@ -1586,12 +1619,23 @@ export function LicenseReviewPage() {
|
||||
</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(
|
||||
@@ -1618,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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFamilyKind } from "@ema-platform/api";
|
||||
import { hasUnclaimedPool, savedViewsForFamily } from "./queue-views";
|
||||
|
||||
describe("resolveFamilyKind", () => {
|
||||
it("treats person-centric seafarer applications as document queues", () => {
|
||||
@@ -8,3 +9,28 @@ describe("resolveFamilyKind", () => {
|
||||
expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUnclaimedPool", () => {
|
||||
it("keeps the unassigned pool for logistics licences", () => {
|
||||
expect(hasUnclaimedPool("FREIGHT_FORWARDER")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps it for vessel registration, a DOCUMENT queue that is still claimed", () => {
|
||||
expect(resolveFamilyKind("VESSEL_REGISTRATION")).toBe("DOCUMENT");
|
||||
expect(hasUnclaimedPool("VESSEL_REGISTRATION")).toBe(true);
|
||||
expect(savedViewsForFamily(hasUnclaimedPool("VESSEL_REGISTRATION")).map((v) => v.id)).toContain(
|
||||
"unassigned",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops it for the person-centric services", () => {
|
||||
expect(hasUnclaimedPool("SEAMAN_BOOK")).toBe(false);
|
||||
expect(savedViewsForFamily(hasUnclaimedPool("SEAMAN_BOOK")).map((v) => v.id)).not.toContain(
|
||||
"unassigned",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps it for the mixed All/Mine grids, where no type is pinned", () => {
|
||||
expect(hasUnclaimedPool(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveFamilyKind } from '@ema-platform/api';
|
||||
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
|
||||
|
||||
export type SavedViewId =
|
||||
@@ -78,12 +79,39 @@ export const SAVED_VIEWS: SavedView[] = [
|
||||
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
|
||||
|
||||
/**
|
||||
* Non-logistics queues (Seafarer Registration, Seaman Book, BTC, CoC, ...)
|
||||
* have no unclaimed pool to triage — those applications aren't claimed off a
|
||||
* shared queue — so the tab that lists it doesn't apply there.
|
||||
* Type keys reviewed off a shared unclaimed pool despite not being logistics
|
||||
* licences. Whether a queue is claimed is an officer-workflow property, not a
|
||||
* label one: vessel registrations arrive unassigned and officers claim them,
|
||||
* even though the family kind is DOCUMENT because the certificate they produce
|
||||
* is a document rather than a licence.
|
||||
*/
|
||||
export function savedViewsForFamily(isLogistics: boolean): SavedView[] {
|
||||
return isLogistics
|
||||
const CLAIMABLE_NON_LOGISTICS = new Set([
|
||||
'VESSEL_REGISTRATION',
|
||||
// Endorsements run the standard claim-first workflow: applications arrive
|
||||
// unassigned and an officer claims them, even though the family kind is
|
||||
// CERTIFICATE.
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Does this queue have an unclaimed pool to triage?
|
||||
*
|
||||
* True for the mixed All/Mine grids (no type pinned) — nothing is hidden when
|
||||
* the queue spans every type. False for the person-centric services (Seafarer
|
||||
* Registration, Seaman Book, BTC, CoC, ...), whose applications aren't claimed
|
||||
* off a shared queue, so the tab and the claim actions don't apply there.
|
||||
*/
|
||||
export function hasUnclaimedPool(typeCode: string | undefined): boolean {
|
||||
if (!typeCode) return true;
|
||||
return (
|
||||
resolveFamilyKind(typeCode) === 'LOGISTICS_LICENSE' ||
|
||||
CLAIMABLE_NON_LOGISTICS.has(typeCode)
|
||||
);
|
||||
}
|
||||
|
||||
/** Drops the Unassigned tab on queues with no unclaimed pool. */
|
||||
export function savedViewsForFamily(claimable: boolean): SavedView[] {
|
||||
return claimable
|
||||
? SAVED_VIEWS
|
||||
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
|
||||
}
|
||||
|
||||
@@ -179,20 +179,21 @@ export type VerificationKind = 'medical' | 'sea-service';
|
||||
*/
|
||||
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
|
||||
const { t } = useTranslation();
|
||||
const isMedical = kind === 'medical';
|
||||
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
||||
const {
|
||||
data: pendingMedical,
|
||||
isLoading: loadingMedical,
|
||||
isFetching: fetchingMedical,
|
||||
refetch: refetchMedical,
|
||||
} = useGetPendingMedicalQuery(filter);
|
||||
} = useGetPendingMedicalQuery(filter, { skip: !isMedical });
|
||||
|
||||
const {
|
||||
data: pendingSeaService,
|
||||
isLoading: loadingSeaService,
|
||||
isFetching: fetchingSeaService,
|
||||
refetch: refetchSeaService,
|
||||
} = useGetPendingSeaServiceQuery(filter);
|
||||
} = useGetPendingSeaServiceQuery(filter, { skip: isMedical });
|
||||
|
||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||
useVerifyMedicalCertificateMutation();
|
||||
@@ -353,8 +354,6 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
||||
[rulingSeaService, rule, verifySeaService, showDate, t],
|
||||
);
|
||||
|
||||
const isMedical = kind === 'medical';
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<PageHeader
|
||||
@@ -376,6 +375,8 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
||||
}
|
||||
/>
|
||||
|
||||
{statusFilter}
|
||||
|
||||
{isMedical ? (
|
||||
<AdvancedTable
|
||||
columns={medicalTableColumns}
|
||||
@@ -391,7 +392,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
||||
onPageSizeChange={handleMedicalPageSizeChange}
|
||||
refresh={refetchMedical}
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
@@ -408,7 +409,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
|
||||
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||
refresh={refetchSeaService}
|
||||
isLoading={loadingSeaService || fetchingSeaService}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconMoon,
|
||||
IconPhone,
|
||||
IconSettings,
|
||||
IconShieldLock,
|
||||
IconSun,
|
||||
@@ -42,7 +41,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
@@ -136,6 +135,9 @@ export function ProfilePage() {
|
||||
register: registerProfile,
|
||||
handleSubmit: handleProfileSubmit,
|
||||
reset: resetProfile,
|
||||
watch: watchProfile,
|
||||
setValue: setValueProfile,
|
||||
trigger: triggerProfile,
|
||||
formState: { errors: profileErrors },
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
@@ -372,11 +374,12 @@ export function ProfilePage() {
|
||||
error={profileErrors.email?.message}
|
||||
{...registerProfile('email')}
|
||||
/>
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('profile.fields.phone')}
|
||||
leftSection={<IconPhone size={18} />}
|
||||
value={watchProfile('phoneNumber') || ''}
|
||||
onChange={(val) => setValueProfile('phoneNumber', val, { shouldValidate: !!profileErrors.phoneNumber })}
|
||||
onBlur={() => triggerProfile('phoneNumber')}
|
||||
error={profileErrors.phoneNumber?.message}
|
||||
{...registerProfile('phoneNumber')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type {
|
||||
Question,
|
||||
QuestionOption,
|
||||
ListResponse,
|
||||
CreateQuestionPayload,
|
||||
UpdateQuestionPayload,
|
||||
ReviewQuestionPayload,
|
||||
SetQuestionOptionsPayload,
|
||||
} from '../types/question';
|
||||
|
||||
const questionApi = baseApi.injectEndpoints({
|
||||
@@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({
|
||||
query: (id) => `/questions/${id}`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
/** Same question, with `options` populated — the MCQ authoring editor. */
|
||||
getQuestionWithOptions: builder.query<Question, string>({
|
||||
query: (id) => `/questions/${id}?i=options`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
|
||||
query: (body) => ({ url: '/questions', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
@@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */
|
||||
setQuestionOptions: builder.mutation<QuestionOption[], SetQuestionOptionsPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/questions/${id}/options`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({
|
||||
export const {
|
||||
useGetQuestionsQuery,
|
||||
useGetQuestionQuery,
|
||||
useGetQuestionWithOptionsQuery,
|
||||
useCreateQuestionMutation,
|
||||
useUpdateQuestionMutation,
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
useSetQuestionOptionsMutation,
|
||||
} = questionApi;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetQuestionWithOptionsQuery,
|
||||
useSetQuestionOptionsMutation,
|
||||
} from '../api/question-api';
|
||||
|
||||
interface DraftOption {
|
||||
text: BilingualValue;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCQ options + correct-answer editor for a CHOICE-form question (Phase 2).
|
||||
*
|
||||
* Only reachable while editing an already-created question — options attach
|
||||
* to a question id, matching the backend's `PUT /questions/:id/options`
|
||||
* full-replace endpoint. Nothing here is ever shown to a candidate; this is
|
||||
* the authoring side only.
|
||||
*/
|
||||
export function QuestionOptionsEditor({ questionId }: { questionId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId);
|
||||
const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation();
|
||||
const [draft, setDraft] = useState<DraftOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
const existing = question.options ?? [];
|
||||
setDraft(
|
||||
existing.length
|
||||
? existing
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((o) => ({ text: o.text, isCorrect: false }))
|
||||
: [
|
||||
{ text: { en: '', am: '' }, isCorrect: false },
|
||||
{ text: { en: '', am: '' }, isCorrect: false },
|
||||
],
|
||||
);
|
||||
// isCorrect never comes back from the API by design — an examiner
|
||||
// re-editing options re-marks the correct one(s) rather than us
|
||||
// pretending to know what they were.
|
||||
}, [question]);
|
||||
|
||||
const updateField = (index: number, lang: keyof BilingualValue, value: string) => {
|
||||
setDraft((prev) =>
|
||||
prev.map((o, i) => (i === index ? { ...o, text: { ...o.text, [lang]: value } } : o)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCorrect = (index: number) => {
|
||||
setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o)));
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]);
|
||||
};
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
setDraft((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (draft.length < 2) {
|
||||
notify.error(t('question.options.needAtLeastTwo'));
|
||||
return;
|
||||
}
|
||||
if (!draft.some((o) => o.isCorrect)) {
|
||||
notify.error(t('question.options.needOneCorrect'));
|
||||
return;
|
||||
}
|
||||
if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) {
|
||||
notify.error(t('question.options.textRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setOptions({ id: questionId, options: draft }).unwrap();
|
||||
notify.success(t('question.options.saved'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) return <Loader size="sm" />;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Alert icon={<IconInfoCircle size={15} />} color="blue" variant="light">
|
||||
{t('question.options.hint')}
|
||||
</Alert>
|
||||
{draft.map((option, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="center">
|
||||
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
label={t('question.options.optionEn', { number: index + 1 })}
|
||||
value={option.text.en}
|
||||
onChange={(e) => updateField(index, 'en', e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t('question.options.optionAm', { number: index + 1 })}
|
||||
value={option.text.am}
|
||||
onChange={(e) => updateField(index, 'am', e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</Stack>
|
||||
<Checkbox
|
||||
label={t('question.options.correct')}
|
||||
checked={option.isCorrect}
|
||||
onChange={() => toggleCorrect(index)}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
disabled={draft.length <= 2}
|
||||
onClick={() => removeOption(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconPlus size={14} />} onClick={addOption}>
|
||||
{t('question.options.addOption')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isSaving} onClick={handleSave}>
|
||||
{t('question.options.save')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{t('question.options.replaceNotice')}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ActionIcon, Button, Group } from '@mantine/core';
|
||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconGavel,
|
||||
IconSend,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -21,52 +27,64 @@ export function questionActionsColumn(
|
||||
cell: ({ row }) => {
|
||||
const q = row.original;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
loading={handlers.isSubmittingReview}
|
||||
>
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<Menu.Item
|
||||
leftSection={<IconSend size={14} />}
|
||||
onClick={() => handlers.onSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||
<Menu.Item color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
||||
{t('question.qc.approve')}
|
||||
</Menu.Item>
|
||||
<Menu.Item color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
||||
{t('question.qc.reject')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||
<Menu.Item
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={14} />}
|
||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={handlers.isSubmittingReview}
|
||||
onClick={() => handlers.onSubmitForApproval(q)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => handlers.onEdit(q)}>
|
||||
{t('question.action.edit', 'Edit')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
{t('question.action.delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import {Stack, Group, Button, Badge, Modal, Text, TextInput, Select, NumberInput, Textarea} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedColumn, AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
Checkbox,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconTrash,
|
||||
IconGripVertical,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
AdvancedColumn,
|
||||
AdvancedTable,
|
||||
ErrorState,
|
||||
ModalFooter,
|
||||
notify,
|
||||
PageHeader,
|
||||
useErrorHandler,
|
||||
useServerTable,
|
||||
} from "@ema-platform/ui";
|
||||
import { extractErrorMessage } from "@ema-platform/api";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||
import {
|
||||
useGetQuestionsQuery,
|
||||
useCreateQuestionMutation,
|
||||
@@ -14,10 +44,92 @@ import {
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
} from '../../api/question-api';
|
||||
import type { Question, QuestionForm } from '../../types/question';
|
||||
import { questionColumns } from './columns';
|
||||
import { questionActionsColumn } from './actions';
|
||||
useSetQuestionOptionsMutation,
|
||||
} from "../../api/question-api";
|
||||
import type {
|
||||
Question,
|
||||
QuestionForm,
|
||||
QuestionOptionInput,
|
||||
} from "../../types/question";
|
||||
import { QuestionOptionsEditor } from "../../components/QuestionOptionsEditor";
|
||||
import { questionColumns } from "./columns";
|
||||
import { questionActionsColumn } from "./actions";
|
||||
|
||||
type DraftOption = { textEn: string; textAm: string; isCorrect: boolean };
|
||||
|
||||
const BLANK_DRAFT_OPTIONS: DraftOption[] = [
|
||||
{ textEn: "", textAm: "", isCorrect: false },
|
||||
{ textEn: "", textAm: "", isCorrect: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Options for a brand-new CHOICE question, entered inline in the same
|
||||
* modal — no question id exists yet, so this is pure local state, only
|
||||
* turned into a real setOptions() call once the question itself is
|
||||
* created (see QuestionPage.handleSubmit).
|
||||
*/
|
||||
function InlineOptionsEditor({
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
options: DraftOption[];
|
||||
onChange: (options: DraftOption[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const update = (index: number, patch: Partial<DraftOption>) =>
|
||||
onChange(options.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{options.map((option, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="center">
|
||||
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
label={t("question.options.optionEn", { number: index + 1 })}
|
||||
value={option.textEn}
|
||||
onChange={(e) => update(index, { textEn: e.currentTarget.value })}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("question.options.optionAm", { number: index + 1 })}
|
||||
value={option.textAm}
|
||||
onChange={(e) => update(index, { textAm: e.currentTarget.value })}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</Stack>
|
||||
<Checkbox
|
||||
label={t("question.options.correct")}
|
||||
checked={option.isCorrect}
|
||||
onChange={() => update(index, { isCorrect: !option.isCorrect })}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
disabled={options.length <= 2}
|
||||
onClick={() => onChange(options.filter((_, i) => i !== index))}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
onChange([...options, { textEn: "", textAm: "", isCorrect: false }])
|
||||
}
|
||||
>
|
||||
{t("question.options.addOption")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionForm({
|
||||
editing,
|
||||
@@ -29,58 +141,181 @@ function QuestionForm({
|
||||
editing: Question | null;
|
||||
certOptions: { value: string; label: string }[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: {
|
||||
certificationId: string;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
form: string;
|
||||
points: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
}, isEdit: boolean) => void;
|
||||
onSubmit: (
|
||||
values: {
|
||||
certificationId: string;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
form: string;
|
||||
points: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
draftOptions: DraftOption[];
|
||||
},
|
||||
isEdit: boolean,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
|
||||
const [certificationId, setCertificationId] = useState<string | null>(
|
||||
editing?.certificationId ?? null,
|
||||
);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
|
||||
|
||||
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
||||
const [points, setPoints] = useState<number>(editing?.points ?? 0);
|
||||
const [days, setDays] = useState(editing?.time?.days ?? 0);
|
||||
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
|
||||
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
|
||||
const [draftOptions, setDraftOptions] =
|
||||
useState<DraftOption[]>(BLANK_DRAFT_OPTIONS);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!certificationId || !titleEn || !titleAm || !form) {
|
||||
notify.error('Please fill all required fields');
|
||||
notify.error("Please fill all required fields");
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
certificationId, titleEn, titleAm, form, points, days, hours, minutes
|
||||
}, !!editing);
|
||||
if (!editing && form === "CHOICE") {
|
||||
if (draftOptions.length < 2) {
|
||||
notify.error(t("question.options.needAtLeastTwo"));
|
||||
return;
|
||||
}
|
||||
if (!draftOptions.some((o) => o.isCorrect)) {
|
||||
notify.error(t("question.options.needOneCorrect"));
|
||||
return;
|
||||
}
|
||||
if (draftOptions.some((o) => !o.textEn.trim() || !o.textAm.trim())) {
|
||||
notify.error(t("question.options.textRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSubmit(
|
||||
{
|
||||
certificationId,
|
||||
titleEn,
|
||||
titleAm,
|
||||
form,
|
||||
points,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
draftOptions: !editing && form === "CHOICE" ? draftOptions : [],
|
||||
},
|
||||
!!editing,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened onClose={onCancel} title={editing ? t('question.update') : t('question.addQuestion')} size="lg">
|
||||
<Modal
|
||||
opened
|
||||
onClose={onCancel}
|
||||
title={editing ? t("question.update") : t("question.addQuestion")}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
||||
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
|
||||
<Select label={t('question.form.form')} placeholder={t('question.form.selectForm')} data={[{ value: 'ESSAY', label: t('question.form.essay') }, { value: 'CHOICE', label: t('question.form.choice') }]} value={form} onChange={setForm} size="sm" required />
|
||||
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
|
||||
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</Text>
|
||||
<Select
|
||||
label={t("question.form.certification")}
|
||||
placeholder={t("question.form.selectCertification")}
|
||||
data={certOptions}
|
||||
value={certificationId}
|
||||
onChange={setCertificationId}
|
||||
size="sm"
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("question.form.titleEn")}
|
||||
placeholder={t("question.form.titleEnPlaceholder")}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("question.form.titleAm")}
|
||||
placeholder={t("question.form.titleAmPlaceholder")}
|
||||
value={titleAm}
|
||||
onChange={(e) => setTitleAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("question.form.form")}
|
||||
placeholder={t("question.form.selectForm")}
|
||||
data={[
|
||||
{ value: "ESSAY", label: t("question.form.essay") },
|
||||
{ value: "CHOICE", label: t("question.form.choice") },
|
||||
]}
|
||||
value={form}
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("question.form.points")}
|
||||
placeholder={t("question.form.pointsPlaceholder")}
|
||||
value={points}
|
||||
onChange={(v) => setPoints(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("question.form.timeAllowed")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
||||
<NumberInput
|
||||
label={t("question.form.days")}
|
||||
value={days}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("question.form.hours")}
|
||||
value={hours}
|
||||
onChange={(v) => setHours(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("question.form.minutes")}
|
||||
value={minutes}
|
||||
onChange={(v) => setMinutes(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
{editing && form === "CHOICE" && (
|
||||
<>
|
||||
<Text fz="sm" fw={500} mt="sm">
|
||||
{t("question.options.title")}
|
||||
</Text>
|
||||
<QuestionOptionsEditor questionId={editing.id} />
|
||||
</>
|
||||
)}
|
||||
{!editing && form === "CHOICE" && (
|
||||
<>
|
||||
<Text fz="sm" fw={500} mt="sm">
|
||||
{t("question.options.title")}
|
||||
</Text>
|
||||
<InlineOptionsEditor
|
||||
options={draftOptions}
|
||||
onChange={setDraftOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("question.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("question.update") : t("question.create")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
@@ -90,7 +325,7 @@ function QuestionForm({
|
||||
|
||||
export function QuestionPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
||||
@@ -98,7 +333,10 @@ export function QuestionPage() {
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||
const [deleteQ] = useDeleteQuestionMutation();
|
||||
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
|
||||
const [setOptions, { isLoading: isSavingOptions }] =
|
||||
useSetQuestionOptionsMutation();
|
||||
const [submitQ, { isLoading: isSubmittingReview }] =
|
||||
useSubmitQuestionMutation();
|
||||
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
|
||||
|
||||
const certifications = certRes?.items ?? [];
|
||||
@@ -107,60 +345,115 @@ export function QuestionPage() {
|
||||
const [editing, setEditing] = useState<Question | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
const [certFilter, setCertFilter] = useState<string | null>(null);
|
||||
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
|
||||
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
|
||||
const [reviewRemark, setReviewRemark] = useState('');
|
||||
const [reviewOutcome, setReviewOutcome] = useState<
|
||||
"APPROVED" | "REJECTED" | "RETIRED"
|
||||
>("APPROVED");
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
|
||||
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
||||
const filtered = questions.filter(
|
||||
(q) => !certFilter || q.certificationId === certFilter,
|
||||
);
|
||||
const page = paginate(filtered);
|
||||
|
||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||
const getCertName = (id: string) =>
|
||||
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||
|
||||
const resetForm = () => { setEditing(null); setShowForm(false); };
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
certificationId: string; titleEn: string; titleAm: string;
|
||||
form: string; points: number; days: number; hours: number; minutes: number;
|
||||
}, isEdit: boolean) => {
|
||||
const handleSubmit = async (
|
||||
values: {
|
||||
certificationId: string;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
form: string;
|
||||
points: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
draftOptions: DraftOption[];
|
||||
},
|
||||
isEdit: boolean,
|
||||
) => {
|
||||
const title = { en: values.titleEn, am: values.titleAm };
|
||||
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
|
||||
const time = {
|
||||
days: values.days,
|
||||
hours: values.hours,
|
||||
minutes: values.minutes,
|
||||
};
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
||||
notify.success(t('question.updated'));
|
||||
await updateQ({
|
||||
id: editing.id,
|
||||
certificationId: values.certificationId,
|
||||
title,
|
||||
form: values.form as QuestionForm,
|
||||
points: values.points,
|
||||
time,
|
||||
}).unwrap();
|
||||
notify.success(t("question.updated"));
|
||||
} else {
|
||||
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
|
||||
notify.success(t('question.created'));
|
||||
const created = await createQ({
|
||||
certificationId: values.certificationId,
|
||||
title,
|
||||
description: { en: "", am: "" },
|
||||
form: values.form as QuestionForm,
|
||||
points: values.points,
|
||||
time,
|
||||
}).unwrap();
|
||||
// The question needs an id to attach options to — this is the second
|
||||
// half of one "create" action from the user's point of view, not a
|
||||
// separate edit step, so it happens right here rather than waiting
|
||||
// for them to reopen the question later.
|
||||
if (values.form === "CHOICE" && values.draftOptions.length) {
|
||||
const options: QuestionOptionInput[] = values.draftOptions.map(
|
||||
(o) => ({
|
||||
text: { en: o.textEn, am: o.textAm },
|
||||
isCorrect: o.isCorrect,
|
||||
}),
|
||||
);
|
||||
await setOptions({ id: created.id, options }).unwrap();
|
||||
}
|
||||
notify.success(t("question.created"));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
notify.error(t("question.error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForApproval = async (question: Question) => {
|
||||
try {
|
||||
await submitQ(question.id).unwrap();
|
||||
notify.success(t('question.qc.submitted'));
|
||||
notify.success(t("question.qc.submitted"));
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
||||
notify.error(extractErrorMessage(error, t("question.qc.error")));
|
||||
}
|
||||
};
|
||||
|
||||
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
|
||||
const openReview = (
|
||||
question: Question,
|
||||
outcome: "APPROVED" | "REJECTED" | "RETIRED",
|
||||
) => {
|
||||
setReviewTarget(question);
|
||||
setReviewOutcome(outcome);
|
||||
setReviewRemark('');
|
||||
setReviewRemark("");
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!reviewTarget) return;
|
||||
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
|
||||
notify.error(t('question.qc.remarkRequired'));
|
||||
if (reviewOutcome !== "APPROVED" && !reviewRemark.trim()) {
|
||||
notify.error(t("question.qc.remarkRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -169,10 +462,10 @@ export function QuestionPage() {
|
||||
outcome: reviewOutcome,
|
||||
remark: reviewRemark.trim() || undefined,
|
||||
}).unwrap();
|
||||
notify.success(t('question.qc.reviewed'));
|
||||
notify.success(t("question.qc.reviewed"));
|
||||
setReviewTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
||||
notify.error(extractErrorMessage(error, t("question.qc.error")));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,7 +473,7 @@ export function QuestionPage() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteQ(deleteTarget.id).unwrap();
|
||||
notify.success(t('question.deleted'));
|
||||
notify.success(t("question.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch (e) {
|
||||
@@ -188,7 +481,8 @@ export function QuestionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError) return <ErrorState title={t('question.loadError')} onRetry={refetch} />;
|
||||
if (isError)
|
||||
return <ErrorState title={t("question.loadError")} onRetry={refetch} />;
|
||||
|
||||
const columns: AdvancedColumn<Question>[] = [
|
||||
...questionColumns(t, { locale, getCertName }),
|
||||
@@ -196,21 +490,35 @@ export function QuestionPage() {
|
||||
isSubmittingReview,
|
||||
onSubmitForApproval: handleSubmitForApproval,
|
||||
onReview: openReview,
|
||||
onEdit: (q) => { setEditing(q); setShowForm(true); },
|
||||
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
|
||||
onEdit: (q) => {
|
||||
setEditing(q);
|
||||
setShowForm(true);
|
||||
},
|
||||
onDelete: (q) => {
|
||||
setDeleteTarget(q);
|
||||
openDelete();
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={t('question.title')}
|
||||
title={t("question.title")}
|
||||
noMargin
|
||||
action={
|
||||
!showForm && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('question.addQuestion')}
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("question.addQuestion")}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)
|
||||
@@ -221,7 +529,7 @@ export function QuestionPage() {
|
||||
<QuestionForm
|
||||
editing={editing}
|
||||
certOptions={certOptions}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
isSubmitting={isCreating || isUpdating || isSavingOptions}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={resetForm}
|
||||
/>
|
||||
@@ -230,66 +538,98 @@ export function QuestionPage() {
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
title={t('question.pool')}
|
||||
tableName={t('question.title')}
|
||||
title={t("question.pool")}
|
||||
tableName={t("question.title")}
|
||||
toolbar={
|
||||
<Select
|
||||
placeholder={t('question.filterByCertification')}
|
||||
data={[{ value: '', label: 'All' }, ...certOptions]}
|
||||
placeholder={t("question.filterByCertification")}
|
||||
data={[{ value: "", label: "All" }, ...certOptions]}
|
||||
value={certFilter}
|
||||
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
|
||||
onChange={(v) => {
|
||||
setCertFilter(v ?? null);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
size="sm"
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
/>
|
||||
}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
/>
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t("question.noQuestions")}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(reviewTarget)}
|
||||
onClose={() => setReviewTarget(null)}
|
||||
title={t('question.qc.reviewTitle')}
|
||||
title={t("question.qc.reviewTitle")}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
|
||||
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
|
||||
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
|
||||
<Text fz="sm" fw={500}>
|
||||
{reviewTarget?.title?.[locale]}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t("question.qc.onlyApprovedUsable")}
|
||||
</Text>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={
|
||||
reviewOutcome === "APPROVED"
|
||||
? "teal"
|
||||
: reviewOutcome === "REJECTED"
|
||||
? "red"
|
||||
: "dark"
|
||||
}
|
||||
w="fit-content"
|
||||
>
|
||||
{t(`question.qc.${reviewOutcome}`)}
|
||||
</Badge>
|
||||
<Textarea
|
||||
label={t('question.qc.remark')}
|
||||
label={t("question.qc.remark")}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={reviewRemark}
|
||||
onChange={(e) => setReviewRemark(e.currentTarget.value)}
|
||||
required={reviewOutcome !== 'APPROVED'}
|
||||
required={reviewOutcome !== "APPROVED"}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
|
||||
{t('question.cancel')}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setReviewTarget(null)}
|
||||
>
|
||||
{t("question.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" loading={isReviewing} onClick={handleReview}>
|
||||
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
|
||||
{t(
|
||||
`question.qc.${reviewOutcome === "APPROVED" ? "approve" : reviewOutcome === "REJECTED" ? "reject" : "retire"}`,
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={t("question.confirmDelete")}
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">{t("question.deleteConfirmText")}</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("question.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">
|
||||
{t("question.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
|
||||
@@ -16,6 +16,17 @@ export type QuestionStatus =
|
||||
| 'REJECTED'
|
||||
| 'RETIRED';
|
||||
|
||||
/**
|
||||
* A CHOICE option, as returned by the authoring/QC endpoints. Never carries
|
||||
* a correctness flag — the API's own answer-key table is never joined into
|
||||
* this response either, so there's nothing to accidentally serialize here.
|
||||
*/
|
||||
export interface QuestionOption {
|
||||
id: string;
|
||||
text: LocalePair;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
certificationId: string;
|
||||
@@ -32,6 +43,8 @@ export interface Question {
|
||||
submittedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Only populated when explicitly requested (`?i=options`). */
|
||||
options?: QuestionOption[];
|
||||
}
|
||||
|
||||
export interface ReviewQuestionPayload {
|
||||
@@ -64,3 +77,13 @@ export interface UpdateQuestionPayload {
|
||||
points?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionOptionInput {
|
||||
text: LocalePair;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
export interface SetQuestionOptionsPayload {
|
||||
id: string;
|
||||
options: QuestionOptionInput[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { IconEye, IconTrash } from '@tabler/icons-react';
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -13,6 +13,7 @@ export function resultActionsColumn(
|
||||
onQc: (result: Result, action: QcAction) => void;
|
||||
onViewDetail: (result: Result) => void;
|
||||
onDelete: (result: Result) => void;
|
||||
onPublish: (result: Result) => void;
|
||||
},
|
||||
): AdvancedColumn<Result> {
|
||||
return {
|
||||
@@ -21,49 +22,66 @@ export function resultActionsColumn(
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Button>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}>
|
||||
{t('result.action.viewEdit')}
|
||||
</Menu.Item>
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||
{t('result.review.approve')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
|
||||
]}
|
||||
hideOnly
|
||||
>
|
||||
<Menu.Item color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
||||
{t('result.review.return')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||
{t('result.review.approve')}
|
||||
</Button>
|
||||
)}
|
||||
{r.reviewStatus === 'APPROVED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item
|
||||
color="teal"
|
||||
leftSection={<IconSend size={14} />}
|
||||
onClick={() => handlers.onPublish(r)}
|
||||
>
|
||||
{t('result.review.publish')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
|
||||
]}
|
||||
hideOnly
|
||||
>
|
||||
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
||||
{t('result.review.return')}
|
||||
</Button>
|
||||
)}
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -95,6 +95,8 @@ export function ResultPage() {
|
||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [publishTarget, setPublishTarget] = useState<Result | null>(null);
|
||||
const [publishOpened, { open: openPublish, close: closePublish }] = useDisclosure(false);
|
||||
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
|
||||
const [detailSaving, setDetailSaving] = useState(false);
|
||||
@@ -231,6 +233,19 @@ export function ResultPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */
|
||||
const handleConfirmPublish = async () => {
|
||||
if (!publishTarget) return;
|
||||
try {
|
||||
const outcome = await publishResults(publishTarget.examId).unwrap();
|
||||
notify.success(t('result.review.publishedCount', outcome));
|
||||
closePublish();
|
||||
setPublishTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailClose = () => {
|
||||
closeDetail();
|
||||
setDetailBreakdowns([]);
|
||||
@@ -257,6 +272,7 @@ export function ResultPage() {
|
||||
onQc: openQc,
|
||||
onViewDetail: viewDetail,
|
||||
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
|
||||
onPublish: (r) => { setPublishTarget(r); openPublish(); },
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -283,9 +299,11 @@ export function ResultPage() {
|
||||
{t('result.review.publish')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
@@ -472,14 +490,22 @@ export function ResultPage() {
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
|
||||
<Button
|
||||
onClick={handleDetailSave}
|
||||
size="sm"
|
||||
loading={detailSaving}
|
||||
leftSection={<IconDeviceFloppy size={15} />}
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
LICENSE_PERMISSIONS.RECORD_EXAM_RESULT,
|
||||
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||
]}
|
||||
hideOnly
|
||||
>
|
||||
{t('result.save')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDetailSave}
|
||||
size="sm"
|
||||
loading={detailSaving}
|
||||
leftSection={<IconDeviceFloppy size={15} />}
|
||||
>
|
||||
{t('result.save')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
) : (
|
||||
@@ -542,6 +568,20 @@ export function ResultPage() {
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publish')} size="sm">
|
||||
<Text mb="md">
|
||||
{t('result.review.publishConfirmText', {
|
||||
exam: publishTarget ? getExamTitle(publishTarget.examId) : '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closePublish} size="sm">{t('result.cancel')}</Button>
|
||||
<Button color="teal" loading={isPublishing} onClick={handleConfirmPublish} size="sm">
|
||||
{t('result.review.publish')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
{/* Choose exam, then record */}
|
||||
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {Alert, Badge, Button, Center, Container, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||
import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, SimpleGrid, Stack, Text, Textarea, ThemeIcon, rem} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
@@ -20,20 +20,35 @@ import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" component="div">
|
||||
{value ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={600} component="div">{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
icon,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<ThemeIcon variant="light" color={color} size={26} radius="md">{icon}</ThemeIcon>
|
||||
<Text fw={700} fz="sm">{title}</Text>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,61 +183,49 @@ export function SeafarerDocumentReviewPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="lg" mb="md">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>
|
||||
{(applicant?.name ?? '??').split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Seafarer
|
||||
</Text>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Name" value={applicant?.name} />
|
||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
||||
<Row
|
||||
label="Registration"
|
||||
value={
|
||||
applicant?.registrationId ? (
|
||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||
{applicant.registrationNumber}
|
||||
</Link>
|
||||
) : (
|
||||
applicant?.registrationNumber
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Text fw={700} fz="lg" lh={1.2}>{applicant?.name ?? '—'}</Text>
|
||||
<Group gap={6} mt={4}>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{applicant?.seafarerNumber ?? '—'}</Text>
|
||||
{applicant?.registrationId && (
|
||||
<>
|
||||
<Text fz="xs" c="dimmed">·</Text>
|
||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||
<Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Payment
|
||||
</Text>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Row label="Provider" value={payment?.provider} />
|
||||
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Issuance
|
||||
</Text>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Row label="Document №" value={document.documentNumber} />
|
||||
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<SectionCard title="Payment" icon={<IconCash size={14} />} color="teal">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stat label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Stat label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Stat label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Stat label="Provider" value={payment?.provider} />
|
||||
<Stat label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Issuance" icon={<IconFileCertificate size={14} />} color="violet">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stat label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Stat label="Document №" value={document.documentNumber} />
|
||||
<Stat label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Stat label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
|
||||
<Stack>
|
||||
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
SEAFARER_REGISTRATION_STATUS_TONES,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
displaySeafarerAnswer,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
useLocalized,
|
||||
type SeafarerRegistration,
|
||||
type SeafarerRegistrationStatus,
|
||||
} from '@ema-platform/api';
|
||||
@@ -28,12 +30,16 @@ export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middl
|
||||
export function SeafarerRegistrationQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [status, setStatus] = useState<SeafarerRegistrationStatus | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
|
||||
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||
|
||||
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
|
||||
status: status ?? undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
@@ -69,7 +75,11 @@ export function SeafarerRegistrationQueuePage() {
|
||||
{
|
||||
header: 'Department',
|
||||
accessorKey: 'department',
|
||||
cell: ({ row }) => <Text size="sm">{displaySeafarerAnswer('department', row.original.department)}</Text>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{displaySeafarerAnswer('department', row.original.department, departmentOptions)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Submitted',
|
||||
@@ -100,7 +110,7 @@ export function SeafarerRegistrationQueuePage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[showDate],
|
||||
[showDate, departmentOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
displaySeafarerAnswer,
|
||||
extractErrorMessage,
|
||||
useApproveSeafarerRegistrationMutation,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useGetSeafarerRegistrationReviewQuery,
|
||||
useLocalized,
|
||||
useRejectSeafarerRegistrationMutation,
|
||||
useRequestSeafarerRegistrationChangesMutation,
|
||||
} from '@ema-platform/api';
|
||||
@@ -31,7 +33,10 @@ const DECISION_COPY: Record<Decision, { title: string; label: string; color: str
|
||||
export function SeafarerRegistrationReviewPage() {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const localized = useLocalized();
|
||||
const { data, isLoading, error } = useGetSeafarerRegistrationReviewQuery(id, { skip: !id });
|
||||
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||
|
||||
const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation();
|
||||
const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
|
||||
@@ -166,7 +171,9 @@ export function SeafarerRegistrationReviewPage() {
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{displaySeafarerAnswer(field, registration[field])}</Text>
|
||||
<Text size="sm">
|
||||
{displaySeafarerAnswer(field, registration[field], departmentOptions)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import {ActionIcon, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
|
||||
import {ActionIcon, Avatar, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -47,66 +48,72 @@ const STATUS_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: '
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={600}>{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
||||
if (!sf) return null;
|
||||
const initials = sf.name.split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase();
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} — {sf.id}</Text></Group>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" size="xs" /></Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Medical Expiry</Text>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
|
||||
<Modal opened={opened} onClose={onClose} size="xl" radius="lg" padding={0} withCloseButton={false}>
|
||||
<Stack gap={0}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" wrap="nowrap" p="lg" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>{initials}</Avatar>
|
||||
<div>
|
||||
<Text fw={700} fz="lg" lh={1.2}>{sf.name}</Text>
|
||||
<Group gap={6} mt={4}>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{sf.id}</Text>
|
||||
<Text fz="xs" c="dimmed">·</Text>
|
||||
<Text fz="xs" c="dimmed">{sf.rank}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" />
|
||||
<ActionIcon variant="subtle" color="gray" onClick={onClose}><IconX size={16} /></ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
|
||||
<Table fz="xs" verticalSpacing="xs">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Stack gap="lg" p="lg">
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="lg">
|
||||
<Stat label="Nationality" value={sf.nationality} />
|
||||
<Stat label="Date of Birth" value={sf.dob} />
|
||||
<Stat label="Seaman Book №" value={sf.seamanBookNo} />
|
||||
<Stat label="SB Expiry" value={sf.seamanBookExpiry} />
|
||||
<Stat label="BTC №" value={sf.btcNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||
<Stat label="BSID №" value={sf.bsidNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||
<Stat label="Medical" value={<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="sm">{sf.medicalStatus}</Badge>} />
|
||||
<Stat label="Medical Expiry" value={sf.medicalExpiry} />
|
||||
</SimpleGrid>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="sm" tt="uppercase">CoC / CoP Certificates</Text>
|
||||
<Stack gap="xs">
|
||||
{sf.cocCerts.map((c) => (
|
||||
<Table.Tr key={c.no}>
|
||||
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
<Group key={c.no} justify="space-between" wrap="nowrap" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="violet" size={30} radius="md"><IconCertificate size={15} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{c.type}</Text>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{c.no}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">Expires {c.expiry}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: "የመርከብ ባለቤትነት ዝውውር",
|
||||
@@ -257,8 +257,10 @@ export const am: Translations = {
|
||||
oral: "ቃል",
|
||||
essay: "ኢሴይ",
|
||||
choice: "ምርጫ",
|
||||
both: "ሁለቱም",
|
||||
offline: "ከመስመር ውጪ",
|
||||
online: "በመስመር",
|
||||
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
||||
sum: "ድምር",
|
||||
average: "አማካይ",
|
||||
percentage: "መቶኛ",
|
||||
@@ -266,6 +268,11 @@ export const am: Translations = {
|
||||
random: "በዘፈቀደ",
|
||||
cuttingPoint: "የማለፊያ ነጥብ",
|
||||
cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ",
|
||||
cuttingPointPercentagePlaceholder: "ለማለፍ ዝቅተኛ መቶኛ (0-100)",
|
||||
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
|
||||
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
|
||||
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||
status: "ሁኔታ",
|
||||
statusPlaceholder: "የፈተና ሁኔታ",
|
||||
pending: "በመጠባበቅ ላይ",
|
||||
@@ -303,6 +310,7 @@ export const am: Translations = {
|
||||
formType: {
|
||||
ESSAY: "ኢሴይ",
|
||||
CHOICE: "ምርጫ",
|
||||
BOTH: "ሁለቱም",
|
||||
},
|
||||
admin: {
|
||||
OFFLINE: "ከመስመር ውጪ",
|
||||
@@ -330,6 +338,10 @@ export const am: Translations = {
|
||||
retake: "ድጋሚ {{n}}",
|
||||
firstSitting: "የመጀመሪያ ሙከራ",
|
||||
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
|
||||
regrade: "እንደገና ደረጃ ስጥ",
|
||||
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
|
||||
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
|
||||
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: "አልተጠራም",
|
||||
@@ -374,6 +386,8 @@ export const am: Translations = {
|
||||
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
|
||||
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
|
||||
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
||||
cannotReachCuttingPoint:
|
||||
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -691,6 +705,8 @@ export const am: Translations = {
|
||||
returned: "ውጤት ወደ ፈታኙ ተመልሷል",
|
||||
publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።",
|
||||
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
|
||||
publishConfirmText:
|
||||
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
|
||||
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
|
||||
originalScore: "የፈታኙ ጠቅላላ",
|
||||
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
|
||||
@@ -782,6 +798,22 @@ export const am: Translations = {
|
||||
onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።",
|
||||
error: "ተግባሩ አልተሳካም",
|
||||
},
|
||||
options: {
|
||||
title: "የመልስ አማራጮች",
|
||||
hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።",
|
||||
optionLabel: "አማራጭ {{number}}",
|
||||
optionEn: "አማራጭ {{number}} (እንግሊዝኛ)",
|
||||
optionAm: "አማራጭ {{number}} (አማርኛ)",
|
||||
correct: "ትክክለኛ",
|
||||
addOption: "አማራጭ ጨምር",
|
||||
save: "አማራጮችን አስቀምጥ",
|
||||
saved: "አማራጮች ተቀምጠዋል",
|
||||
saveFirst: "መጀመሪያ ጥያቄውን አስቀምጥ፣ ከዚያ አማራጮችን ጨምር።",
|
||||
replaceNotice: "ትክክለኛ መልሶች ከተቀመጡ በኋላ እዚህ አይታዩም — እንደገና ካስተካከልክ/ካስተካከልሽ ዳግም ምረጥ/ምረጪ።",
|
||||
needAtLeastTwo: "ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልገዋል።",
|
||||
needOneCorrect: "ቢያንስ አንድ አማራጭ እንደ ትክክለኛ ምረጥ/ምረጪ።",
|
||||
textRequired: "እያንዳንዱ አማራጭ በሁለቱም ቋንቋዎች ጽሑፍ ያስፈልገዋል።",
|
||||
},
|
||||
},
|
||||
|
||||
configuration: {
|
||||
@@ -984,6 +1016,7 @@ export const am: Translations = {
|
||||
reportReview: "ለቡድን መሪ አሳውቅ",
|
||||
assignInspector: "ምርመራ መድብ",
|
||||
reportInspection: "የምርመራ ውጤት አሳውቅ",
|
||||
assignReviewer: "ገምጋሚ መድብ",
|
||||
escalate: "ወደ ላይ አሳድግ",
|
||||
hold: "አግድ",
|
||||
resume: "ቀጥል",
|
||||
@@ -995,7 +1028,10 @@ export const am: Translations = {
|
||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||
reject: "አትቀበል",
|
||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||
recordExamOutcome: "የፈተና ውጤት መዝግብ",
|
||||
confirmPayment: "ክፍያ አረጋግጥ",
|
||||
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
|
||||
issueCertificate: "ሰርተፍኬት ስጥ",
|
||||
print: "ሰነድ አትም",
|
||||
copyLink: "አገናኝ ቅዳ",
|
||||
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",
|
||||
@@ -1009,10 +1045,14 @@ export const am: Translations = {
|
||||
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
||||
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
||||
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
||||
inspectionNotYetDue:
|
||||
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
|
||||
needsDocumentReviews:
|
||||
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
|
||||
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
|
||||
},
|
||||
inspectionFailedBlocked:
|
||||
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
|
||||
reasons: {
|
||||
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
||||
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
|
||||
@@ -1039,6 +1079,7 @@ export const am: Translations = {
|
||||
hold: "ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።",
|
||||
resume: "ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።",
|
||||
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
||||
"assign-reviewer": "ማመልከቻ {{number}} ለተመረጠው ሹም ሰጥቶ ግምገማውን ያስጀምራል።",
|
||||
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
||||
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
|
||||
},
|
||||
@@ -1133,6 +1174,7 @@ export const am: Translations = {
|
||||
resume: "ማመልከቻው ቀጥሏል",
|
||||
escalate: "ወደ ላይ አድጓል",
|
||||
assign: "እንደገና ተመድቧል",
|
||||
assignReviewer: "ግምገማ ተመድቧል",
|
||||
scheduled: "ምርመራ ተይዟል",
|
||||
inspectionPassed: "ምርመራ አልፏል",
|
||||
inspectionFailed: "ምርመራ ወድቋል",
|
||||
|
||||
@@ -48,6 +48,7 @@ export const en = {
|
||||
typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer',
|
||||
typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
|
||||
typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
|
||||
typeENDORSEMENT_SEAFARER: 'Seafarer Endorsement (CoC / GOC)',
|
||||
typeENDORSEMENT_COC: 'CoC Endorsement',
|
||||
typeENDORSEMENT_GOC: 'GOC Endorsement',
|
||||
primary: 'Primary',
|
||||
@@ -87,8 +88,7 @@ export const en = {
|
||||
postWaiverQueue: 'Post-Waiver Queue',
|
||||
cocQueue: 'CoC Queue',
|
||||
copQueue: 'CoP Queue',
|
||||
endorsementCocQueue: 'CoC Endorsement Queue',
|
||||
endorsementGocQueue: 'GOC Endorsement Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Ownership Transfer',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
@@ -256,8 +256,10 @@ export const en = {
|
||||
oral: 'Oral',
|
||||
essay: 'Essay',
|
||||
choice: 'Choice',
|
||||
both: 'Both',
|
||||
offline: 'Offline',
|
||||
online: 'Online',
|
||||
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
||||
sum: 'Sum',
|
||||
average: 'Average',
|
||||
percentage: 'Percentage',
|
||||
@@ -265,6 +267,11 @@ export const en = {
|
||||
random: 'Random',
|
||||
cuttingPoint: 'Cutting Point (Pass Mark)',
|
||||
cuttingPointPlaceholder: 'Minimum score to pass',
|
||||
cuttingPointPercentagePlaceholder: 'Minimum % to pass (0-100)',
|
||||
cuttingPointPercentageHint: 'Percentage evaluation — capped at 100.',
|
||||
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.',
|
||||
status: 'Status',
|
||||
statusPlaceholder: 'Exam status',
|
||||
pending: 'Pending',
|
||||
@@ -301,6 +308,7 @@ export const en = {
|
||||
formType: {
|
||||
ESSAY: 'Essay',
|
||||
CHOICE: 'Choice',
|
||||
BOTH: 'Both',
|
||||
},
|
||||
admin: {
|
||||
OFFLINE: 'Offline',
|
||||
@@ -328,6 +336,10 @@ export const en = {
|
||||
retake: 'Retake {{n}}',
|
||||
firstSitting: 'First sitting',
|
||||
remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
|
||||
regrade: 'Regrade',
|
||||
regraded: 'Result created from the graded attempt.',
|
||||
regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.',
|
||||
regradeError: 'Could not regrade this attempt.',
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: 'Not called',
|
||||
@@ -373,6 +385,8 @@ export const en = {
|
||||
randomError: 'Could not draw questions',
|
||||
notEnoughApproved:
|
||||
'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.',
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -692,6 +706,8 @@ export const en = {
|
||||
returned: 'Result returned to the examiner',
|
||||
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
|
||||
publishNeedsExam: 'Filter by an exam first to publish its results.',
|
||||
publishConfirmText:
|
||||
'This publishes every approved result for {{exam}} — not just this one — and notifies each candidate. Continue?',
|
||||
lockedAfterApproval:
|
||||
'This result is approved and can no longer be edited. Return it to the examiner first.',
|
||||
originalScore: 'Examiner total',
|
||||
@@ -785,6 +801,23 @@ export const en = {
|
||||
'Only approved items can be placed on an examination paper.',
|
||||
error: 'Operation failed',
|
||||
},
|
||||
options: {
|
||||
title: 'Answer Options',
|
||||
hint: 'Mark every correct option. Saving replaces the entire option set.',
|
||||
optionLabel: 'Option {{number}}',
|
||||
optionEn: 'Option {{number}} (English)',
|
||||
optionAm: 'Option {{number}} (Amharic)',
|
||||
correct: 'Correct',
|
||||
addOption: 'Add option',
|
||||
save: 'Save options',
|
||||
saved: 'Options saved',
|
||||
saveFirst: 'Save the question first, then add its options.',
|
||||
replaceNotice:
|
||||
'Correct answers are never shown here once saved — re-mark them if you edit this set again.',
|
||||
needAtLeastTwo: 'A question needs at least two options.',
|
||||
needOneCorrect: 'Mark at least one option as correct.',
|
||||
textRequired: 'Every option needs text in both languages.',
|
||||
},
|
||||
},
|
||||
|
||||
configuration: {
|
||||
@@ -992,6 +1025,7 @@ export const en = {
|
||||
reportReview: 'Report to team leader',
|
||||
assignInspector: 'Assign inspection',
|
||||
reportInspection: 'Report inspection result',
|
||||
assignReviewer: 'Assign reviewer',
|
||||
escalate: 'Escalate',
|
||||
hold: 'Put on hold',
|
||||
resume: 'Resume',
|
||||
@@ -1003,7 +1037,10 @@ export const en = {
|
||||
requestAdjustment: 'Request adjustment',
|
||||
reject: 'Reject',
|
||||
scheduleExam: 'Schedule exam',
|
||||
recordExamOutcome: 'Record exam outcome',
|
||||
confirmPayment: 'Confirm payment',
|
||||
scheduleIssuance: 'Schedule pickup',
|
||||
issueCertificate: 'Issue certificate',
|
||||
print: 'Print dossier',
|
||||
copyLink: 'Copy link',
|
||||
downloadDocuments: 'Download all documents',
|
||||
@@ -1017,10 +1054,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',
|
||||
@@ -1046,6 +1087,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}}.',
|
||||
},
|
||||
@@ -1137,6 +1180,7 @@ export const en = {
|
||||
resume: 'Application resumed',
|
||||
escalate: 'Escalated',
|
||||
assign: 'Reassigned',
|
||||
assignReviewer: 'Review assigned',
|
||||
scheduled: 'Inspection scheduled',
|
||||
inspectionPassed: 'Inspection passed',
|
||||
inspectionFailed: 'Inspection failed',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AppShell, Drawer } from '@mantine/core';
|
||||
import { AppShell, Box, Drawer, Group, Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -27,6 +27,14 @@ const BADGE_POLL_MS = 60_000;
|
||||
|
||||
const HEADER_HEIGHT = 116;
|
||||
|
||||
/**
|
||||
* Horizontal inset of the header chrome. `AppHeader` adds its own `px="lg"`
|
||||
* inside this, so the nav strip below needs the sum to line up with the
|
||||
* controls above it — it used to start 20px to their left.
|
||||
*/
|
||||
const CHROME_PAD_X = 32;
|
||||
const NAV_STRIP_PAD_X = CHROME_PAD_X + 20;
|
||||
|
||||
/**
|
||||
* A desk left unlocked with a license-review or medical-record screen open is
|
||||
* the actual threat model here, not a slow token. 15 minutes of no mouse,
|
||||
@@ -149,7 +157,9 @@ export function BackofficeLayout() {
|
||||
the 20-plus nav items instead of tabbing through them every time. */}
|
||||
<SkipLink />
|
||||
<AppShell
|
||||
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
|
||||
// The top layout drops its nav strip on small screens — the drawer is
|
||||
// the nav there — so the header shrinks back to a single row with it.
|
||||
header={{ height: isSidebar ? 74 : { base: 74, sm: HEADER_HEIGHT } }}
|
||||
navbar={
|
||||
isSidebar
|
||||
? {
|
||||
@@ -167,13 +177,28 @@ export function BackofficeLayout() {
|
||||
<AppShell.Header
|
||||
style={{
|
||||
background: "var(--mantine-color-body)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
|
||||
<div
|
||||
style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }}
|
||||
>
|
||||
<AppHeader
|
||||
brand={
|
||||
isSidebar ? undefined : (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<BrandMark size={28} />
|
||||
<Text fw={700} size="sm" lh={1.1} visibleFrom="xs">
|
||||
{t('app.name')}
|
||||
</Text>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
// Nothing to toggle on a desktop top bar; on mobile it opens the
|
||||
// drawer below.
|
||||
burgerHiddenFrom={isSidebar ? undefined : 'sm'}
|
||||
onToggleNav={toggleNav}
|
||||
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
||||
navOpened={opened}
|
||||
@@ -187,13 +212,14 @@ export function BackofficeLayout() {
|
||||
</div>
|
||||
|
||||
{!isSidebar && (
|
||||
<div
|
||||
<Box
|
||||
visibleFrom="sm"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 32px',
|
||||
padding: `0 ${NAV_STRIP_PAD_X}px`,
|
||||
height: 42,
|
||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
||||
borderTop: '1px solid var(--mantine-color-default-border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
@@ -204,7 +230,7 @@ export function BackofficeLayout() {
|
||||
activePath={location.pathname}
|
||||
onNavigate={go}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</AppShell.Header>
|
||||
|
||||
@@ -215,7 +241,7 @@ export function BackofficeLayout() {
|
||||
overflow: "hidden",
|
||||
transition: "width 200ms ease",
|
||||
background: "var(--mantine-color-body)",
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRight: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
@@ -243,30 +269,28 @@ export function BackofficeLayout() {
|
||||
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
|
||||
click) instead of AppShell's full-width mobile navbar. Mirrors the
|
||||
landing page's mobile menu. */}
|
||||
{isSidebar && (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={closeNav}
|
||||
hiddenFrom="sm"
|
||||
size="75%"
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={sections}
|
||||
collapsed={false}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onNavigate={(item) => {
|
||||
go(item);
|
||||
closeNav();
|
||||
}}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={closeNav}
|
||||
hiddenFrom="sm"
|
||||
size="75%"
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={sections}
|
||||
collapsed={false}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onNavigate={(item) => {
|
||||
go(item);
|
||||
closeNav();
|
||||
}}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</Drawer>
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -103,6 +103,12 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ 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_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] },
|
||||
],
|
||||
|
||||
@@ -90,7 +90,11 @@ const router = createBrowserRouter([
|
||||
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
||||
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
// Endorsement review happens in the config-driven licence queue.
|
||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
||||
// CoC and GOC endorsement now share one combined type; the two old
|
||||
// type queues (ENDORSEMENT_COC / ENDORSEMENT_GOC) are left routing
|
||||
// through the generic `:typeCode` queue below rather than redirected,
|
||||
// so an application filed before the switch stays reachable there.
|
||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_SEAFARER" replace /> },
|
||||
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useBypassPaymentMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -31,9 +32,17 @@ import {
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
import { endorsementColumns } from './columns';
|
||||
|
||||
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
|
||||
// ENDORSEMENT_SEAFARER covers CoC and GOC together and is the only type new
|
||||
// applications file against; the other two stay listed so an application or
|
||||
// licence filed before the switch keeps showing up here.
|
||||
const ENDORSEMENT_TYPE_KEYS = [
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
];
|
||||
|
||||
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
@@ -74,6 +83,8 @@ export function EndorsementPage() {
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const issuedTable = useServerTable();
|
||||
|
||||
const registered =
|
||||
@@ -90,6 +101,22 @@ export function EndorsementPage() {
|
||||
);
|
||||
const issuedPage = issuedTable.paginate(issued);
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notify.success(
|
||||
result.certificateIssued
|
||||
? t('endorsement.bypassIssued', 'Payment bypassed — the endorsement has been issued.')
|
||||
: t('endorsement.bypassOk', {
|
||||
defaultValue: 'Payment bypassed — application is now {{status}}.',
|
||||
status: result.status.replace(/_/g, ' ').toLowerCase(),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, t('endorsement.bypassFailed', 'Bypass failed')));
|
||||
}
|
||||
}
|
||||
|
||||
async function download(licenseId: string) {
|
||||
try {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
@@ -132,21 +159,13 @@ export function EndorsementPage() {
|
||||
/>
|
||||
</List>
|
||||
</div>
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
|
||||
>
|
||||
{t('endorsement.endorseCoc', 'Endorse a CoC')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
|
||||
>
|
||||
{t('endorsement.endorseGoc', 'Endorse a GOC')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
disabled={!registered}
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_SEAFARER/apply')}
|
||||
>
|
||||
{t('endorsement.apply', 'Apply for an endorsement')}
|
||||
</Button>
|
||||
</Group>
|
||||
{!registered && (
|
||||
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
||||
@@ -183,6 +202,36 @@ export function EndorsementPage() {
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
|
||||
</Badge>
|
||||
{app.status === 'PAYMENT_PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="yellow"
|
||||
loading={isPaying}
|
||||
onClick={() => pay(app.id)}
|
||||
>
|
||||
{t('endorsement.pay', {
|
||||
defaultValue: 'Pay {{amount}} {{currency}}',
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
{/* ponytail: shown unconditionally for the testing
|
||||
phase — the server still refuses it unless
|
||||
ALLOW_PAYMENT_BYPASS is set and NODE_ENV is not
|
||||
production. Re-gate on useGetPaymentCapabilitiesQuery
|
||||
(like MyApplicationsPage) before prod. */}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
loading={bypassing}
|
||||
onClick={() => handleBypass(app.id)}
|
||||
title="Testing only — marks the fee paid"
|
||||
>
|
||||
{t('endorsement.bypass', 'Bypass payment')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Card, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||
import { IconCircleCheck, IconClockPause } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { AttemptStatus } from '../types/exam-attempt';
|
||||
|
||||
/**
|
||||
* No score, no pass/fail, nothing evaluation-shaped — grading hasn't run.
|
||||
* This only confirms what actually happened: the candidate submitted, or
|
||||
* the deadline closed the attempt out first.
|
||||
*/
|
||||
export function ExamCompletion({
|
||||
status,
|
||||
submittedAt,
|
||||
}: {
|
||||
status: AttemptStatus;
|
||||
submittedAt: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const expired = status === 'EXPIRED';
|
||||
|
||||
return (
|
||||
<Stack maw={520} mx="auto" align="center" py="xl">
|
||||
<Card withBorder radius="lg" p="xl" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<ThemeIcon size={64} radius="xl" variant="light" color={expired ? 'orange' : 'teal'}>
|
||||
{expired ? <IconClockPause size={32} /> : <IconCircleCheck size={32} />}
|
||||
</ThemeIcon>
|
||||
<Title order={3} ta="center">
|
||||
{expired ? 'Time expired' : 'Exam submitted'}
|
||||
</Title>
|
||||
<Text ta="center" c="dimmed">
|
||||
{expired
|
||||
? 'The scheduled time ran out. Your saved answers were recorded as your final submission.'
|
||||
: 'Your answers have been recorded.'}
|
||||
{' '}Your result will appear on the Examinations page once marking, moderation and
|
||||
approval are complete — it is not available yet.
|
||||
</Text>
|
||||
{submittedAt && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
<Button variant="light" onClick={() => navigate('/exams')}>
|
||||
Back to Examinations
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt';
|
||||
|
||||
function formatDuration(time: EstimatedTime | null | undefined): string {
|
||||
if (!time) return 'Not configured';
|
||||
const parts = [
|
||||
time.days ? `${time.days}d` : null,
|
||||
time.hours ? `${time.hours}h` : null,
|
||||
time.minutes ? `${time.minutes}m` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : '0m';
|
||||
}
|
||||
|
||||
export function ExamInstructions({
|
||||
registration,
|
||||
localized,
|
||||
showDate,
|
||||
starting,
|
||||
onStart,
|
||||
}: {
|
||||
registration: RegistrationWithExam;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
starting: boolean;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const exam = registration.exam;
|
||||
const canStart = exam?.status === 'ACTIVE';
|
||||
|
||||
return (
|
||||
<Stack maw={720} mx="auto" gap="md">
|
||||
<Title order={2}>{localized(exam?.title) || 'Examination'}</Title>
|
||||
<Text c="dimmed">{localized(exam?.certification?.name)}</Text>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Admission number</Text>
|
||||
<Text fz="sm" fw={600} ff="monospace">{registration.admissionNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Session date</Text>
|
||||
<Text fz="sm">{showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Duration</Text>
|
||||
<Badge variant="light" leftSection={<IconClock size={12} />}>
|
||||
{formatDuration(exam?.givenTime)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Attempt</Text>
|
||||
<Badge variant="light" color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}>
|
||||
{registration.kind === 'RETAKE'
|
||||
? `Retake · sitting ${registration.attemptNumber}`
|
||||
: 'First sitting'}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{exam?.direction && localized(exam.direction) && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" title="Instructions">
|
||||
{localized(exam.direction)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light">
|
||||
Once started, the timer cannot be paused. Answers are saved automatically as you go, but
|
||||
the exam ends the moment the deadline passes, whether or not you have submitted.
|
||||
</Alert>
|
||||
|
||||
{!canStart && (
|
||||
<Alert color="gray" variant="light">
|
||||
This session is not currently open for candidates to begin.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
loading={starting}
|
||||
disabled={!canStart}
|
||||
onClick={onStart}
|
||||
>
|
||||
Start exam
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core';
|
||||
import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import type { CandidateQuestion, SaveState } from '../types/exam-attempt';
|
||||
|
||||
function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) {
|
||||
if (state === 'saving') {
|
||||
return <Text fz="xs" c="dimmed">Saving…</Text>;
|
||||
}
|
||||
if (state === 'saved') {
|
||||
return (
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal">Saved</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<Group gap={6}>
|
||||
<IconAlertCircle size={13} color="var(--mantine-color-red-6)" />
|
||||
<Text fz="xs" c="red">Not saved</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconRefresh size={12} />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one question — never the answer key, because the API response
|
||||
* this reads from (`CandidateQuestion`/`CandidateOption`) has no such field
|
||||
* to render even by mistake.
|
||||
*/
|
||||
export function ExamQuestionDisplay({
|
||||
question,
|
||||
index,
|
||||
total,
|
||||
localized,
|
||||
selectedOptionId,
|
||||
answerText,
|
||||
saveState,
|
||||
disabled,
|
||||
onSelectOption,
|
||||
onChangeText,
|
||||
onRetry,
|
||||
}: {
|
||||
question: CandidateQuestion;
|
||||
index: number;
|
||||
total: number;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
selectedOptionId: string | null | undefined;
|
||||
answerText: string | null | undefined;
|
||||
saveState: SaveState;
|
||||
disabled: boolean;
|
||||
onSelectOption: (optionId: string) => void;
|
||||
onChangeText: (text: string) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Badge variant="light" color="gray">
|
||||
Question {index + 1} of {total} · {question.points} pts
|
||||
</Badge>
|
||||
<SaveIndicator state={saveState} onRetry={onRetry} />
|
||||
</Group>
|
||||
|
||||
<Text fz="md" fw={500} mb="lg">
|
||||
{localized(question.title)}
|
||||
</Text>
|
||||
|
||||
{question.form === 'CHOICE' ? (
|
||||
<Radio.Group
|
||||
value={selectedOptionId ?? ''}
|
||||
onChange={onSelectOption}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{question.options
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((option) => (
|
||||
<Radio.Card
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={disabled}
|
||||
p="sm"
|
||||
radius="md"
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Radio.Indicator disabled={disabled} />
|
||||
<Text fz="sm">{localized(option.text)}</Text>
|
||||
</Group>
|
||||
</Radio.Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Write your answer"
|
||||
minRows={8}
|
||||
autosize
|
||||
disabled={disabled}
|
||||
value={answerText ?? ''}
|
||||
onChange={(event) => onChangeText(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Paper, SimpleGrid, Text, UnstyledButton } from '@mantine/core';
|
||||
import type { CandidateQuestion } from '../types/exam-attempt';
|
||||
|
||||
export function ExamQuestionNav({
|
||||
questions,
|
||||
currentIndex,
|
||||
answeredIds,
|
||||
disabled,
|
||||
onJump,
|
||||
}: {
|
||||
questions: CandidateQuestion[];
|
||||
currentIndex: number;
|
||||
answeredIds: Set<string>;
|
||||
disabled: boolean;
|
||||
onJump: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Text fz="xs" fw={600} c="dimmed" mb="xs" tt="uppercase">
|
||||
Questions
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing={6}>
|
||||
{questions.map((q, index) => {
|
||||
const answered = answeredIds.has(q.id);
|
||||
const current = index === currentIndex;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={q.id}
|
||||
disabled={disabled}
|
||||
onClick={() => onJump(index)}
|
||||
style={{
|
||||
height: 34,
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
border: current ? '2px solid var(--mantine-color-blue-6)' : '1px solid var(--mantine-color-gray-4)',
|
||||
background: answered
|
||||
? 'var(--mantine-color-teal-1)'
|
||||
: 'var(--mantine-color-body)',
|
||||
color: answered ? 'var(--mantine-color-teal-8)' : undefined,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<Text fz="xs" c="dimmed" mt="sm">
|
||||
{answeredIds.size} of {questions.length} answered
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Badge, Group } from '@mantine/core';
|
||||
import { IconClock } from '@tabler/icons-react';
|
||||
|
||||
function format(totalSeconds: number): string {
|
||||
const s = Math.max(0, totalSeconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display only. `remainingSeconds` is a local countdown seeded once from the
|
||||
* server's own clock (`AttemptSession.remainingSeconds`/`serverTime`) and
|
||||
* ticked down client-side — the deadline it represents is enforced by the
|
||||
* backend on every save/submit regardless of whether this number is right.
|
||||
*/
|
||||
export function ExamTimer({ remainingSeconds }: { remainingSeconds: number }) {
|
||||
const low = remainingSeconds <= 300; // 5 minutes
|
||||
return (
|
||||
<Group gap={6}>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={low ? 'red' : 'blue'}
|
||||
leftSection={<IconClock size={14} />}
|
||||
ff="monospace"
|
||||
>
|
||||
{format(remainingSeconds)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApiMutation, useApiQuery, extractErrorMessage } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import type {
|
||||
AttemptSession,
|
||||
CandidateAnswer,
|
||||
ExamAttempt,
|
||||
RegistrationWithExam,
|
||||
SaveState,
|
||||
} from '../types/exam-attempt';
|
||||
|
||||
const ESSAY_DEBOUNCE_MS = 1500;
|
||||
|
||||
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
||||
|
||||
type LocalAnswer = { selectedOptionId?: string | null; answerText?: string | null };
|
||||
|
||||
/**
|
||||
* All state and API orchestration for taking one exam. Kept out of the page
|
||||
* component so the component tree stays about rendering, not about save
|
||||
* timers and expiry races.
|
||||
*
|
||||
* Nothing here is a security boundary — every write still goes through the
|
||||
* backend's own ownership + `applyExpiry()` checks on every call. This hook
|
||||
* only decides what to show; a client that skipped straight to calling the
|
||||
* API directly would hit exactly the same server-side rules.
|
||||
*/
|
||||
export function useExamAttempt(examId: string | undefined) {
|
||||
const [session, setSession] = useState<AttemptSession | null>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, LocalAnswer>>({});
|
||||
const [saveStates, setSaveStates] = useState<Record<string, SaveState>>({});
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||
|
||||
const answersRef = useRef(answers);
|
||||
answersRef.current = answers;
|
||||
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const seeded = useRef(false);
|
||||
|
||||
const {
|
||||
data: registrations,
|
||||
isLoading: loadingRegistrations,
|
||||
} = useApiQuery<RegistrationWithExam[]>({ url: '/exams/registrations/mine' });
|
||||
const registration = registrations?.find((r) => r.exam?.id === examId);
|
||||
|
||||
const {
|
||||
data: mineData,
|
||||
isLoading: loadingMine,
|
||||
isError: mineIsError,
|
||||
error: mineError,
|
||||
refetch: refetchMine,
|
||||
} = useApiQuery<AttemptSession>(
|
||||
{ url: `/exam-attempts/mine/${examId}` },
|
||||
{ skip: !examId },
|
||||
);
|
||||
|
||||
const [startTrigger, { isLoading: starting }] = useApiMutation<AttemptSession>();
|
||||
const [answerTrigger] = useApiMutation<CandidateAnswer>();
|
||||
const [submitTrigger, { isLoading: submitting }] = useApiMutation<ExamAttempt>();
|
||||
|
||||
const seedFrom = useCallback((data: AttemptSession) => {
|
||||
setSession(data);
|
||||
const map: Record<string, LocalAnswer> = {};
|
||||
for (const a of data.answers) {
|
||||
map[a.questionId] = { selectedOptionId: a.selectedOptionId, answerText: a.answerText };
|
||||
}
|
||||
setAnswers(map);
|
||||
setRemainingSeconds(data.remainingSeconds);
|
||||
setViewState(data.attempt.status === 'IN_PROGRESS' ? 'taking' : 'completed');
|
||||
}, []);
|
||||
|
||||
// Seed once from the initial load — after that, local state (ticking
|
||||
// timer, in-flight edits) is the source of truth, not this query.
|
||||
useEffect(() => {
|
||||
if (seeded.current) return;
|
||||
if (loadingRegistrations || loadingMine) return;
|
||||
seeded.current = true;
|
||||
|
||||
if (!registration) {
|
||||
setViewState('error');
|
||||
setErrorMessage('You are not registered for this examination.');
|
||||
return;
|
||||
}
|
||||
if (mineData) {
|
||||
seedFrom(mineData);
|
||||
return;
|
||||
}
|
||||
if (mineIsError) {
|
||||
const key = extractErrorMessage(mineError, '');
|
||||
if (key === 'attempt_not_found') {
|
||||
setViewState('not-started');
|
||||
return;
|
||||
}
|
||||
setViewState('error');
|
||||
setErrorMessage(extractErrorMessage(mineError, 'Could not load the exam.'));
|
||||
}
|
||||
}, [loadingRegistrations, loadingMine, registration, mineData, mineIsError, mineError, seedFrom]);
|
||||
|
||||
/** Authoritative resync — used after any write is refused as expired/submitted. */
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const result = await refetchMine();
|
||||
if (result.data) {
|
||||
seedFrom(result.data as AttemptSession);
|
||||
} else {
|
||||
setViewState('error');
|
||||
setErrorMessage(extractErrorMessage(result.error, 'The exam session ended.'));
|
||||
}
|
||||
}, [refetchMine, seedFrom]);
|
||||
|
||||
const persistAnswer = useCallback(
|
||||
async (questionId: string, payload: LocalAnswer) => {
|
||||
if (!session) return;
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'saving' }));
|
||||
try {
|
||||
const saved = await answerTrigger({
|
||||
url: `/exam-attempts/${session.attempt.id}/answers`,
|
||||
method: 'POST',
|
||||
body: { questionId, ...payload },
|
||||
}).unwrap();
|
||||
setAnswers((a) => ({
|
||||
...a,
|
||||
[questionId]: { selectedOptionId: saved.selectedOptionId, answerText: saved.answerText },
|
||||
}));
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'saved' }));
|
||||
} catch (error) {
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'error' }));
|
||||
const key = extractErrorMessage(error, '');
|
||||
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||
notify.error(
|
||||
key === 'attempt_expired'
|
||||
? 'Time is up — this answer was not saved.'
|
||||
: 'This attempt has already been submitted.',
|
||||
);
|
||||
syncFromServer();
|
||||
}
|
||||
}
|
||||
},
|
||||
[session, answerTrigger, syncFromServer],
|
||||
);
|
||||
|
||||
const flush = useCallback(
|
||||
(questionId: string) => {
|
||||
const timer = debounceTimers.current[questionId];
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
delete debounceTimers.current[questionId];
|
||||
const current = answersRef.current[questionId];
|
||||
if (current) persistAnswer(questionId, current);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const selectOption = useCallback(
|
||||
(questionId: string, optionId: string) => {
|
||||
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], selectedOptionId: optionId } }));
|
||||
persistAnswer(questionId, { selectedOptionId: optionId });
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const changeText = useCallback(
|
||||
(questionId: string, text: string) => {
|
||||
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], answerText: text } }));
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'idle' }));
|
||||
clearTimeout(debounceTimers.current[questionId]);
|
||||
debounceTimers.current[questionId] = setTimeout(() => {
|
||||
delete debounceTimers.current[questionId];
|
||||
persistAnswer(questionId, { answerText: text });
|
||||
}, ESSAY_DEBOUNCE_MS);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
const current = session?.questions[currentIndex];
|
||||
if (current) flush(current.id);
|
||||
setCurrentIndex(index);
|
||||
},
|
||||
[session, currentIndex, flush],
|
||||
);
|
||||
|
||||
const retry = useCallback(
|
||||
(questionId: string) => {
|
||||
const current = answersRef.current[questionId];
|
||||
if (current) persistAnswer(questionId, current);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!examId) return;
|
||||
try {
|
||||
const result = await startTrigger({
|
||||
url: '/exam-attempts/start',
|
||||
method: 'POST',
|
||||
body: { examId },
|
||||
}).unwrap();
|
||||
seedFrom(result);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
|
||||
}
|
||||
}, [examId, startTrigger, seedFrom]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!session) return;
|
||||
const current = session.questions[currentIndex];
|
||||
if (current) flush(current.id);
|
||||
try {
|
||||
const attempt = await submitTrigger({
|
||||
url: `/exam-attempts/${session.attempt.id}/submit`,
|
||||
method: 'POST',
|
||||
}).unwrap();
|
||||
setSession((s) => (s ? { ...s, attempt } : s));
|
||||
setViewState('completed');
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, '');
|
||||
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||
syncFromServer();
|
||||
} else {
|
||||
notify.error(extractErrorMessage(error, 'Could not submit the exam.'));
|
||||
}
|
||||
}
|
||||
}, [session, currentIndex, flush, submitTrigger, syncFromServer]);
|
||||
|
||||
/** Local countdown only — every write is still checked server-side regardless. */
|
||||
useEffect(() => {
|
||||
if (viewState !== 'taking') return;
|
||||
const id = setInterval(() => {
|
||||
setRemainingSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(id);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [viewState]);
|
||||
|
||||
// Time reaching zero locally: stop taking input, tell the server, then
|
||||
// trust whatever it reports back over anything computed in the browser.
|
||||
const timedOutRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (viewState !== 'taking' || remainingSeconds > 0 || timedOutRef.current) return;
|
||||
timedOutRef.current = true;
|
||||
notify.error("Time's up.");
|
||||
// submit() itself resyncs from the server if this loses the race against
|
||||
// applyExpiry() — either way the final state comes from the backend, not
|
||||
// from this timer having reached zero.
|
||||
submit();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remainingSeconds, viewState]);
|
||||
|
||||
const answeredIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
Object.entries(answers)
|
||||
.filter(([, v]) => v.selectedOptionId || v.answerText?.trim())
|
||||
.map(([id]) => id),
|
||||
),
|
||||
[answers],
|
||||
);
|
||||
|
||||
return {
|
||||
viewState,
|
||||
errorMessage,
|
||||
registration,
|
||||
session,
|
||||
currentIndex,
|
||||
answers,
|
||||
saveStates,
|
||||
answeredIds,
|
||||
remainingSeconds,
|
||||
starting,
|
||||
submitting,
|
||||
start,
|
||||
selectOption,
|
||||
changeText,
|
||||
goTo,
|
||||
retry,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Alert, Button, Center, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||
import { IconAlertCircle, IconSend } from '@tabler/icons-react';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { useExamAttempt } from '../../hooks/useExamAttempt';
|
||||
import { ExamInstructions } from '../../components/ExamInstructions';
|
||||
import { ExamTimer } from '../../components/ExamTimer';
|
||||
import { ExamQuestionNav } from '../../components/ExamQuestionNav';
|
||||
import { ExamQuestionDisplay } from '../../components/ExamQuestionDisplay';
|
||||
import { ExamCompletion } from '../../components/ExamCompletion';
|
||||
|
||||
/**
|
||||
* The candidate exam-taking screen (Phase 4). Route: `/exams/:examId/take`.
|
||||
*
|
||||
* All state/API orchestration lives in `useExamAttempt` — this component is
|
||||
* the view: pick which of loading/not-started/taking/completed/error to
|
||||
* render. Every write it triggers (start, save, submit) is re-checked by the
|
||||
* backend regardless of what this screen currently shows; nothing here is
|
||||
* the actual security boundary.
|
||||
*/
|
||||
export function ExamAttemptPage() {
|
||||
const { examId } = useParams<{ examId: string }>();
|
||||
const localized = useLocalized();
|
||||
const showDate = useDateDisplayer();
|
||||
const [confirmOpened, setConfirmOpened] = useState(false);
|
||||
|
||||
const {
|
||||
viewState,
|
||||
errorMessage,
|
||||
registration,
|
||||
session,
|
||||
currentIndex,
|
||||
answers,
|
||||
saveStates,
|
||||
answeredIds,
|
||||
remainingSeconds,
|
||||
starting,
|
||||
submitting,
|
||||
start,
|
||||
selectOption,
|
||||
changeText,
|
||||
goTo,
|
||||
retry,
|
||||
submit,
|
||||
} = useExamAttempt(examId);
|
||||
|
||||
if (viewState === 'loading') {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'error') {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" maw={600} mx="auto" mt="xl">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'not-started') {
|
||||
if (!registration) return null; // guarded by 'error' above, appeases TS
|
||||
return (
|
||||
<ExamInstructions
|
||||
registration={registration}
|
||||
localized={localized}
|
||||
showDate={showDate}
|
||||
starting={starting}
|
||||
onStart={start}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'completed' && session) {
|
||||
return (
|
||||
<ExamCompletion status={session.attempt.status} submittedAt={session.attempt.submittedAt} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) return null; // 'taking' always has a session by construction
|
||||
|
||||
const question = session.questions[currentIndex];
|
||||
const answer = answers[question.id];
|
||||
|
||||
return (
|
||||
<Stack maw={1000} mx="auto" gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600}>{localized(registration?.exam?.title) || 'Examination in progress'}</Text>
|
||||
<ExamTimer remainingSeconds={remainingSeconds} />
|
||||
</Group>
|
||||
|
||||
<Group align="flex-start" gap="md" wrap="wrap-reverse">
|
||||
<div style={{ flex: 1, minWidth: 280 }}>
|
||||
<ExamQuestionDisplay
|
||||
question={question}
|
||||
index={currentIndex}
|
||||
total={session.questions.length}
|
||||
localized={localized}
|
||||
selectedOptionId={answer?.selectedOptionId}
|
||||
answerText={answer?.answerText}
|
||||
saveState={saveStates[question.id] ?? 'idle'}
|
||||
disabled={remainingSeconds <= 0}
|
||||
onSelectOption={(optionId) => selectOption(question.id, optionId)}
|
||||
onChangeText={(text) => changeText(question.id, text)}
|
||||
onRetry={() => retry(question.id)}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={currentIndex === 0}
|
||||
onClick={() => goTo(currentIndex - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
{currentIndex < session.questions.length - 1 ? (
|
||||
<Button onClick={() => goTo(currentIndex + 1)}>Next</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconSend size={15} />}
|
||||
onClick={() => setConfirmOpened(true)}
|
||||
>
|
||||
Submit exam
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 220, flexShrink: 0 }}>
|
||||
<ExamQuestionNav
|
||||
questions={session.questions}
|
||||
currentIndex={currentIndex}
|
||||
answeredIds={answeredIds}
|
||||
disabled={remainingSeconds <= 0}
|
||||
onJump={goTo}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconSend size={15} />}
|
||||
onClick={() => setConfirmOpened(true)}
|
||||
>
|
||||
Submit exam
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={confirmOpened}
|
||||
onClose={() => setConfirmOpened(false)}
|
||||
title="Submit this exam?"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
{answeredIds.size} of {session.questions.length} questions answered. Once submitted,
|
||||
answers cannot be changed.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setConfirmOpened(false)}>
|
||||
Keep working
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
loading={submitting}
|
||||
onClick={async () => {
|
||||
await submit();
|
||||
setConfirmOpened(false);
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExamAttemptPage;
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
|
||||
export type AttemptStatus = 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
|
||||
export type QuestionForm = 'ESSAY' | 'CHOICE';
|
||||
|
||||
export interface CandidateOption {
|
||||
id: string;
|
||||
text: Bilingual;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Never carries a correct-answer flag — the API doesn't send one. */
|
||||
export interface CandidateQuestion {
|
||||
id: string;
|
||||
title: Bilingual;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options: CandidateOption[];
|
||||
}
|
||||
|
||||
export interface ExamAttempt {
|
||||
id: string;
|
||||
examId: string;
|
||||
registrationId: string;
|
||||
status: AttemptStatus;
|
||||
startedAt: string;
|
||||
expiresAt: string;
|
||||
submittedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CandidateAnswer {
|
||||
id: string;
|
||||
attemptId: string;
|
||||
questionId: string;
|
||||
selectedOptionId: string | null;
|
||||
answerText: string | null;
|
||||
}
|
||||
|
||||
/** Response shape shared by POST /exam-attempts/start and GET .../mine/:examId. */
|
||||
export interface AttemptSession {
|
||||
attempt: ExamAttempt;
|
||||
questions: CandidateQuestion[];
|
||||
answers: CandidateAnswer[];
|
||||
serverTime: string;
|
||||
remainingSeconds: number;
|
||||
}
|
||||
|
||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
export interface EstimatedTime {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `GET /exams/registrations/mine`'s response this feature
|
||||
* reads — the endpoint returns the full raw exam/registration, this is
|
||||
* just this feature's own narrow view of it (matches the sibling `exams`
|
||||
* feature's pattern of each screen typing only what it uses).
|
||||
*/
|
||||
export interface RegistrationWithExam {
|
||||
id: string;
|
||||
admissionNumber: string;
|
||||
kind: 'NEW' | 'RETAKE';
|
||||
attemptNumber: number;
|
||||
attendanceStatus: string;
|
||||
exam?: {
|
||||
id: string;
|
||||
title: Bilingual;
|
||||
direction?: Bilingual;
|
||||
date: string;
|
||||
venue: string | null;
|
||||
status: string;
|
||||
givenTime: EstimatedTime | null;
|
||||
certification?: { name?: Bilingual };
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
||||
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
@@ -20,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||
|
||||
export function registrationColumns(
|
||||
t: TFunction,
|
||||
deps: {
|
||||
@@ -28,6 +30,7 @@ export function registrationColumns(
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
onDownloadSlip: (registration: MyRegistration) => void;
|
||||
onStartExam: (registration: MyRegistration) => void;
|
||||
},
|
||||
): AdvancedColumn<MyRegistration>[] {
|
||||
return [
|
||||
@@ -91,6 +94,44 @@ export function registrationColumns(
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
header: t('exams.columns.exam'),
|
||||
cell: ({ row }) => {
|
||||
const exam = row.original.exam;
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
// Already finished — no restart, no more room for "Take exam" to
|
||||
// invite a click that the backend would just refuse.
|
||||
if (attemptStatus === 'SUBMITTED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{t('exams.columns.completed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (attemptStatus === 'EXPIRED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
{t('exams.columns.timeExpired')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const eligible =
|
||||
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
||||
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconPlayerPlay size={13} />}
|
||||
onClick={() => deps.onStartExam(row.original)}
|
||||
>
|
||||
{attemptStatus === 'IN_PROGRESS'
|
||||
? t('exams.columns.resumeExam')
|
||||
: t('exams.columns.takeExam')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -53,6 +54,8 @@ export interface MyRegistration {
|
||||
attemptNumber: number;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
exam?: OpenExam;
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||
}
|
||||
|
||||
export interface MyResult {
|
||||
@@ -80,6 +83,7 @@ export interface MyAppeal {
|
||||
*/
|
||||
export function ExamsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||
@@ -245,6 +249,7 @@ export function ExamsPage() {
|
||||
localized,
|
||||
showDate,
|
||||
onDownloadSlip: downloadSlip,
|
||||
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
|
||||
})}
|
||||
data={pagedRegistrations.rows}
|
||||
itemCount={pagedRegistrations.itemCount}
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
conditionHolds,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useGetRanksQuery,
|
||||
useLocalized,
|
||||
type Bilingual,
|
||||
type Department,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type Rank,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
||||
@@ -82,6 +87,41 @@ export function fillFromVessel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for a SELECT field.
|
||||
*
|
||||
* A department/rank field's seed `options` are a label cache that goes stale
|
||||
* the moment a backoffice admin adds a department or rank — the field's
|
||||
* value is already resolved server-side (profile department, or
|
||||
* `eligibility.nextRank`), correct either way, but a value missing from a
|
||||
* stale cache renders as a blank Select. Live-fetched departments/ranks take
|
||||
* over the labels for these two `source`s; the seed's own `options` still
|
||||
* cover every other SELECT unchanged.
|
||||
*/
|
||||
function selectOptions(
|
||||
field: FormFieldConfig,
|
||||
currentValue: string | undefined,
|
||||
departments: Department[] | undefined,
|
||||
ranks: Rank[],
|
||||
localized: (v?: Bilingual) => string,
|
||||
): { value: string; label: string }[] {
|
||||
if (field.source === 'profile.seafarerDepartment' && departments) {
|
||||
return departments.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||
}
|
||||
if (field.source === 'eligibility.nextRank') {
|
||||
const options = ranks.map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||
// The resolved rank might not be on THIS field's ladder (rank/rankEngine,
|
||||
// proficiencyDeck/Engine share one `eligibility.nextRank` source but only
|
||||
// one is ever populated) — still show it rather than a blank Select.
|
||||
if (currentValue && !options.some((o) => o.value === currentValue)) {
|
||||
const known = ranks.find((r) => r.key === currentValue);
|
||||
options.push({ value: currentValue, label: known ? localized(known.name) : currentValue });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
return (field.options ?? []).map((o) => ({ value: o.value, label: localized(o.label) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one form section from the license type's configuration.
|
||||
*
|
||||
@@ -105,6 +145,21 @@ export function ConfigDrivenSection({
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
|
||||
// A department/rank SELECT ships with a hardcoded `options` label list in
|
||||
// the seed, so a department or rank added later in the backoffice has no
|
||||
// label there and would render as a blank Select even though the field's
|
||||
// value (resolved server-side) is correct. Skipped when the section has
|
||||
// neither kind of field, so most sections never pay for these two queries.
|
||||
const needsDepartmentLabels = fields.some(
|
||||
(f) => f.source === 'profile.seafarerDepartment',
|
||||
);
|
||||
const needsRankLabels = fields.some((f) => f.source === 'eligibility.nextRank');
|
||||
const { data: departments } = useGetActiveDepartmentsQuery(undefined, {
|
||||
skip: !needsDepartmentLabels,
|
||||
});
|
||||
const { data: rankRes } = useGetRanksQuery(undefined, { skip: !needsRankLabels });
|
||||
const ranks = rankRes?.items ?? [];
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{fields.map((field) => {
|
||||
@@ -183,10 +238,7 @@ export function ConfigDrivenSection({
|
||||
) : field.type === 'SELECT' ? (
|
||||
<Select
|
||||
{...common}
|
||||
data={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label),
|
||||
}))}
|
||||
data={selectOptions(field, value as string | undefined, departments, ranks, localized)}
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
clearable={!field.required}
|
||||
|
||||
@@ -322,24 +322,44 @@ export function LicenseApplicationPage() {
|
||||
const isAdjusting = application?.status === "RESUBMIT_REQUIRED";
|
||||
const openRemarks = detail?.openRemarks ?? [];
|
||||
|
||||
// The whole round's remarks, resolved or not — the server's section lock
|
||||
// (`assertSectionUnlocked`) ignores `isResolved`, and this page bulk-resolves
|
||||
// remarks right before resubmitting, so `openRemarks` would re-freeze a
|
||||
// section the moment the applicant ticked it off.
|
||||
const roundRemarks = useMemo(
|
||||
() =>
|
||||
(detail?.remarks ?? []).filter(
|
||||
(r) => r.roundNumber === detail?.application?.adjustmentRound,
|
||||
),
|
||||
[detail?.remarks, detail?.application?.adjustmentRound],
|
||||
);
|
||||
|
||||
const flaggedSections = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks
|
||||
roundRemarks
|
||||
.filter((r) => r.targetType === "FORM_SECTION")
|
||||
.map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
[roundRemarks],
|
||||
);
|
||||
const flaggedDocuments = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks
|
||||
roundRemarks
|
||||
.filter((r) => r.targetType === "DOCUMENT")
|
||||
.map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
[roundRemarks],
|
||||
);
|
||||
const hasSectionRemarks = Object.keys(flaggedSections).length > 0;
|
||||
const hasDocRemarks = Object.keys(flaggedDocuments).length > 0;
|
||||
|
||||
// A round that flagged no form sections carries no section locks — mirror of
|
||||
// the server's fallback, without which a documents-only correction round
|
||||
// froze every field and the applicant could not edit anything at all.
|
||||
const isSectionLocked = (sectionKey: string) =>
|
||||
isAdjusting && hasSectionRemarks && !flaggedSections[sectionKey];
|
||||
|
||||
// Sections that share a group collapse onto one step, so the stepper stays
|
||||
// short instead of showing a page per section.
|
||||
@@ -356,6 +376,14 @@ export function LicenseApplicationPage() {
|
||||
[steps],
|
||||
);
|
||||
|
||||
// A section-level showWhen can remove a step while the wizard is open
|
||||
// (ENDORSEMENT_SEAFARER's certificate sections follow the chosen scope).
|
||||
// Clamp so `steps[active]` can never go out of bounds if a seed ever lets
|
||||
// a later answer hide an earlier step.
|
||||
useEffect(() => {
|
||||
if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1));
|
||||
}, [active, steps.length]);
|
||||
|
||||
if (loadingConfig || !config || !appId || !application) {
|
||||
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
||||
}
|
||||
@@ -375,6 +403,24 @@ export function LicenseApplicationPage() {
|
||||
// to the summary first.
|
||||
const showSummary = application.status !== "DRAFT" && viewingSummary;
|
||||
|
||||
// The applicant reads "Vessel Particulars", not "vesselParticulars" — and a
|
||||
// staff remark is keyed by a uuid, which reads as nothing at all.
|
||||
function remarkLabel(remark: (typeof openRemarks)[number]): string {
|
||||
if (remark.targetType === "FORM_SECTION") {
|
||||
const section = config?.licenseType.formSchema.sections.find(
|
||||
(s) => s.key === remark.targetKey,
|
||||
);
|
||||
return section ? localized(section.title) : remark.targetKey;
|
||||
}
|
||||
if (remark.targetType === "DOCUMENT") {
|
||||
const requirement = config?.documentRequirements.find(
|
||||
(r) => r.key === remark.targetKey,
|
||||
);
|
||||
return requirement ? localized(requirement.name) : remark.targetKey;
|
||||
}
|
||||
return t("licenseApplication.staffMember", "Staff member");
|
||||
}
|
||||
|
||||
// Vessel Information and Current Ownership are separate form sections, so
|
||||
// ConfigDrivenSection (one instance per section) can't fill both itself —
|
||||
// it reports the pick up here and this fans it out across every section.
|
||||
@@ -392,7 +438,7 @@ export function LicenseApplicationPage() {
|
||||
async function saveSection(sectionKey: string) {
|
||||
// During an adjustment round only flagged sections are editable, so don't
|
||||
// even attempt a write the server would reject.
|
||||
if (isAdjusting && !flaggedSections[sectionKey]) return;
|
||||
if (isSectionLocked(sectionKey)) return;
|
||||
const values = { ...(draft[sectionKey] ?? {}) };
|
||||
// The picker works in alpha-2 codes (CountrySelect); the backend, like
|
||||
// the profile Address endpoint, stores the full country name.
|
||||
@@ -671,7 +717,7 @@ export function LicenseApplicationPage() {
|
||||
<Stack gap={4}>
|
||||
{openRemarks.map((remark) => (
|
||||
<Text size="sm" key={remark.id}>
|
||||
<b>{remark.targetKey}</b>: {remark.remark}
|
||||
<b>{remarkLabel(remark)}</b>: {remark.remark}
|
||||
</Text>
|
||||
))}
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
@@ -733,7 +779,7 @@ export function LicenseApplicationPage() {
|
||||
{currentStep?.kind === "sections" && (
|
||||
<Stack gap="lg">
|
||||
{currentStep.sections.map((section, index) => {
|
||||
const locked = isAdjusting && !flaggedSections[section.key];
|
||||
const locked = isSectionLocked(section.key);
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{index > 0 && <Divider mb="lg" />}
|
||||
@@ -891,7 +937,7 @@ export function LicenseApplicationPage() {
|
||||
ownerType="APPLICATION"
|
||||
ownerId={appId}
|
||||
flagged={flaggedDocuments}
|
||||
restrictToFlagged={isAdjusting}
|
||||
restrictToFlagged={isAdjusting && hasDocRemarks}
|
||||
readOnly={readOnly}
|
||||
onUploaded={() => {
|
||||
refetchAttachments();
|
||||
@@ -914,7 +960,10 @@ export function LicenseApplicationPage() {
|
||||
formData={draft}
|
||||
errors={fieldErrors}
|
||||
vessels={vessels}
|
||||
disabled={readOnly}
|
||||
// Same lock as the earlier steps — without it this step
|
||||
// looked editable during an adjustment round while
|
||||
// saveSection silently dropped the changes.
|
||||
disabled={readOnly || isSectionLocked(section.key)}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -55,6 +55,10 @@ const MODE_FREE_TYPE_KEYS = [
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
// Retired by ENDORSEMENT_SEAFARER but kept mode-free: an in-flight
|
||||
// application filed against one of these before the switch must still be
|
||||
// reachable to view, correct or resubmit.
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
'PRE_WAIVER',
|
||||
|
||||
@@ -16,10 +16,16 @@ import { OperationsFormContent } from "../../profile/components/OperationsFormCo
|
||||
* Seafarer goes to its own registration page, whose Identity Details step
|
||||
* collects the profile answers itself — no detour via `/profile`. Seafarer
|
||||
* wins when both are ticked; the other form is one nav click away.
|
||||
*
|
||||
* SEAFARER_REGISTRATION is listed before ENDORSEMENT_SEAFARER deliberately:
|
||||
* `nextStepFor` takes the first key that matches, and an applicant who ticked
|
||||
* both belongs in registration first — the endorsement application refuses
|
||||
* submission until that registration is accepted.
|
||||
*/
|
||||
const NEXT_STEP: Record<string, string> = {
|
||||
SEAFARER_REGISTRATION: "/seafarer-registration",
|
||||
VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply",
|
||||
VESSEL_REGISTRATION: "/vessel-registration",
|
||||
ENDORSEMENT_SEAFARER: "/endorsements",
|
||||
};
|
||||
|
||||
function nextStepFor(selectedKeys: string[]): string {
|
||||
|
||||
@@ -25,15 +25,21 @@ import { notify, ModalFooter } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/**
|
||||
* Registrations an applicant makes for themselves rather than for a company.
|
||||
* Registrations and seafarer-only services an applicant declares for
|
||||
* themselves rather than for a company.
|
||||
*
|
||||
* Named explicitly rather than inferred from `requiresOperatorMode: false`,
|
||||
* because that flag is also false for things nobody declares up front — a
|
||||
* waiver is requested per shipment, not adopted as an identity.
|
||||
* ENDORSEMENT_SEAFARER belongs here for the same reason as the two
|
||||
* registrations: a seafarer requesting one is not declaring a logistics mode
|
||||
* of operation, and without this key `accountTypeFor` below would fall the
|
||||
* account through to the company-representative type instead of `SEAFARER`.
|
||||
*/
|
||||
const PERSONAL_REGISTRATION_KEYS = [
|
||||
'SEAFARER_REGISTRATION',
|
||||
'VESSEL_REGISTRATION',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,6 +62,7 @@ function accountTypeFor(keys: string[]): string | null {
|
||||
}
|
||||
if (keys.includes('VESSEL_REGISTRATION')) return 'VESSEL_OWNER';
|
||||
if (keys.includes('SEAFARER_REGISTRATION')) return 'SEAFARER';
|
||||
if (keys.includes('ENDORSEMENT_SEAFARER')) return 'SEAFARER';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -212,7 +219,7 @@ export function OperationsFormContent({
|
||||
{personalOptions.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" fw={600} c="dimmed" mt="sm" tt="uppercase">
|
||||
Registering as an individual or vessel owner
|
||||
Registering or applying as an individual or vessel owner
|
||||
</Text>
|
||||
{personalOptions.map((type) => (
|
||||
<Checkbox
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } fr
|
||||
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
isEthiopianNationality,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
} from '@ema-platform/api';
|
||||
@@ -10,22 +11,25 @@ import {
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** The document slots a registration asks for. */
|
||||
export function documentSlots(passportDeclared: boolean) {
|
||||
export function documentSlots(passportDeclared: boolean, nationality?: string | null) {
|
||||
const ethiopian = isEthiopianNationality(nationality);
|
||||
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
|
||||
...d,
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required,
|
||||
})).filter((d) => d.required !== 'passport' || passportDeclared);
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
|
||||
})).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
|
||||
}
|
||||
|
||||
export function RegistrationDocuments({
|
||||
registrationId,
|
||||
passportDeclared,
|
||||
nationality,
|
||||
attachments,
|
||||
readOnly,
|
||||
onUploaded,
|
||||
}: {
|
||||
registrationId: string;
|
||||
passportDeclared: boolean;
|
||||
nationality?: string | null;
|
||||
attachments: Attachment[];
|
||||
readOnly?: boolean;
|
||||
onUploaded: () => void;
|
||||
@@ -62,7 +66,7 @@ export function RegistrationDocuments({
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{documentSlots(passportDeclared).map((slot) => {
|
||||
{documentSlots(passportDeclared, nationality).map((slot) => {
|
||||
const existing = attachments.find((a) => a.documentKey === slot.key);
|
||||
const uploaded = Boolean(existing?.files?.length);
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_SECTIONS,
|
||||
displaySeafarerAnswer,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
type Attachment,
|
||||
type SaveSeafarerRegistration,
|
||||
} from '@ema-platform/api';
|
||||
@@ -16,6 +18,10 @@ export function RegistrationSummary({
|
||||
answers: SaveSeafarerRegistration;
|
||||
attachments?: Attachment[];
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
|
||||
@@ -35,7 +41,9 @@ export function RegistrationSummary({
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text>
|
||||
<Text size="sm">
|
||||
{displaySeafarerAnswer(field, answers[field], departmentOptions)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
GENDER_OPTIONS,
|
||||
HAIR_COLOR_OPTIONS,
|
||||
MARITAL_STATUS_OPTIONS,
|
||||
isEthiopianNationality,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
DateField,
|
||||
@@ -42,6 +45,7 @@ function SectionTitle({ title, description }: { title: string; description?: str
|
||||
export function IdentityDetailsStep(
|
||||
p: StepProps & { account: { email?: string; phoneNumber?: string } },
|
||||
) {
|
||||
const ethiopian = isEthiopianNationality(p.form.nationality);
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Contact Details" />
|
||||
@@ -66,26 +70,55 @@ export function IdentityDetailsStep(
|
||||
<DateField {...p} name="dateOfBirth" label="Date of Birth" required />
|
||||
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
|
||||
<NationalityField {...p} name="nationality" label="Nationality" required />
|
||||
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
|
||||
{ethiopian ? (
|
||||
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
|
||||
) : (
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
required
|
||||
maxLength={32}
|
||||
description="Required for non-Ethiopian applicants in place of a National ID."
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step 2 — Identity, Address and Physical Characteristics. */
|
||||
/**
|
||||
* Step 2 — Identity, Address and Physical Characteristics.
|
||||
*
|
||||
* The department list is backoffice-managed (see the Ranks & Departments
|
||||
* configuration tab), so this fetches the live set rather than a fixed
|
||||
* three — falling back to it only until the query resolves, so the field
|
||||
* is never an empty flash.
|
||||
*/
|
||||
export function ApplicantDetailsStep(p: StepProps) {
|
||||
const localized = useLocalized();
|
||||
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||
const departmentOptions =
|
||||
departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ??
|
||||
DEPARTMENT_OPTIONS;
|
||||
// Non-Ethiopians already declare their passport number as their primary ID
|
||||
// on the Identity step — asking again here would just duplicate the field.
|
||||
const ethiopian = isEthiopianNationality(p.form.nationality);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Identity" />
|
||||
<Grid>
|
||||
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
maxLength={32}
|
||||
description="Required later for international sea service; optional at registration."
|
||||
/>
|
||||
{ethiopian && (
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
maxLength={32}
|
||||
description="Required later for international sea service; optional at registration."
|
||||
/>
|
||||
)}
|
||||
{p.form.passportNumber && (
|
||||
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
|
||||
)}
|
||||
@@ -94,7 +127,7 @@ export function ApplicantDetailsStep(p: StepProps) {
|
||||
name="department"
|
||||
label="Department"
|
||||
required
|
||||
options={DEPARTMENT_OPTIONS}
|
||||
options={departmentOptions}
|
||||
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -8,13 +9,14 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil } from '@tabler/icons-react';
|
||||
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil, IconTrash } from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
PHYSICAL_BOUNDS,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
isEthiopianNationality,
|
||||
useCancelSeafarerRegistrationMutation,
|
||||
useGetAttachmentsQuery,
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
@@ -48,15 +52,26 @@ const STEPS = [
|
||||
{ label: 'Review', description: 'Check & submit' },
|
||||
];
|
||||
|
||||
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
|
||||
/**
|
||||
* Which answers each step must have before "Continue" — mirrors the API's
|
||||
* submission check. National ID vs Passport Number depends on the declared
|
||||
* nationality, so that slot is added dynamically in `requiredForStep`.
|
||||
*/
|
||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
|
||||
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||
[],
|
||||
['declarationAccepted'],
|
||||
];
|
||||
|
||||
/** Ethiopians must give a National ID; everyone else must give a Passport Number instead. */
|
||||
function requiredForStep(index: number, nationality: string | null | undefined): AnswerKey[] {
|
||||
const base = REQUIRED_BY_STEP[index] ?? [];
|
||||
if (index !== 0) return base;
|
||||
return [...base, isEthiopianNationality(nationality) ? 'nationalIdNumber' : 'passportNumber'];
|
||||
}
|
||||
|
||||
const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
|
||||
|
||||
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
|
||||
@@ -102,6 +117,12 @@ function withProfileDefaults(
|
||||
emergencyContactPhone: a?.emergencyContactPhone || null,
|
||||
emergencyContactRelationship: a?.emergencyContactRelation || null,
|
||||
department: profile.seafarerDepartment || null,
|
||||
// Deepest saved level the picker's maxDepth of 3 can show.
|
||||
locationId: a?.subCityId || a?.cityId || a?.regionId || null,
|
||||
hairColor: (profile.hairColor as SaveSeafarerRegistration['hairColor']) || null,
|
||||
eyeColor: (profile.eyeColor as SaveSeafarerRegistration['eyeColor']) || null,
|
||||
bloodType: (profile.bloodType as SaveSeafarerRegistration['bloodType']) || null,
|
||||
heightCm: profile.heightCm ?? null,
|
||||
};
|
||||
const next = { ...answers };
|
||||
for (const [key, value] of Object.entries(defaults) as [AnswerKey, unknown][]) {
|
||||
@@ -119,15 +140,18 @@ function withProfileDefaults(
|
||||
* still missing. A submitted registration opens to a read-only summary.
|
||||
*/
|
||||
export function SeafarerRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const accountUser = useAppSelector((state) => state.auth.user);
|
||||
const { profile } = useCurrentProfile();
|
||||
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
|
||||
const registration = data?.registration ?? null;
|
||||
|
||||
const [start] = useStartSeafarerRegistrationMutation();
|
||||
const [cancelDraft, { isLoading: cancelling }] = useCancelSeafarerRegistrationMutation();
|
||||
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
|
||||
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
||||
const started = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,7 +228,7 @@ export function SeafarerRegistrationPage() {
|
||||
|
||||
function validateStep(index: number): boolean {
|
||||
const found: Partial<Record<AnswerKey, string>> = {};
|
||||
for (const key of REQUIRED_BY_STEP[index] ?? []) {
|
||||
for (const key of requiredForStep(index, form.nationality)) {
|
||||
if (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
|
||||
}
|
||||
if (index === 1) {
|
||||
@@ -232,7 +256,7 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
if (index === 3) {
|
||||
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
|
||||
const missing = documentSlots(Boolean(form.passportNumber))
|
||||
const missing = documentSlots(Boolean(form.passportNumber), form.nationality)
|
||||
.filter((d) => d.isRequired && !supplied.has(d.key))
|
||||
.map((d) => d.name);
|
||||
if (missing.length) {
|
||||
@@ -302,6 +326,19 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!registration) return;
|
||||
try {
|
||||
await cancelDraft(registration.id).unwrap();
|
||||
notifications.show({ color: 'teal', title: 'Draft discarded', message: 'Nothing was saved.' });
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
notifications.show({ color: 'red', title: 'Could not discard the draft', message: extractErrorMessage(err) });
|
||||
} finally {
|
||||
setConfirmingCancel(false);
|
||||
}
|
||||
}
|
||||
|
||||
const stepProps = { form, set, errors, disabled: readOnly };
|
||||
|
||||
return (
|
||||
@@ -319,17 +356,30 @@ export function SeafarerRegistrationPage() {
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
{showSummary && !readOnly && (
|
||||
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
||||
Edit details
|
||||
</Button>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
{registration.status === 'DRAFT' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => setConfirmingCancel(true)}
|
||||
>
|
||||
Cancel & discard draft
|
||||
</Button>
|
||||
)}
|
||||
{showSummary && !readOnly && (
|
||||
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
||||
Edit details
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{registration.status === 'APPROVED' && (
|
||||
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
|
||||
You are a registered seafarer. Your seafarer number is <b>{registration.seafarerNumber}</b>.
|
||||
Your Seaman Book and Basic Training Certificate applications have been opened for you.
|
||||
You can now apply for a certificate endorsement from the Endorsement Seafarer page.
|
||||
</Alert>
|
||||
)}
|
||||
{registration.status === 'REJECTED' && (
|
||||
@@ -386,6 +436,7 @@ export function SeafarerRegistrationPage() {
|
||||
<RegistrationDocuments
|
||||
registrationId={registration.id}
|
||||
passportDeclared={Boolean(form.passportNumber)}
|
||||
nationality={form.nationality}
|
||||
attachments={attachments}
|
||||
readOnly={readOnly}
|
||||
onUploaded={refetchAttachments}
|
||||
@@ -426,6 +477,23 @@ export function SeafarerRegistrationPage() {
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Modal opened={confirmingCancel} onClose={() => setConfirmingCancel(false)} title="Discard this draft?" centered>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
Everything you have entered will be deleted, including any documents already uploaded. This cannot be
|
||||
undone. You can start a new registration at any time.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setConfirmingCancel(false)} disabled={cancelling}>
|
||||
Keep draft
|
||||
</Button>
|
||||
<Button color="red" loading={cancelling} onClick={handleCancel}>
|
||||
Discard draft
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,13 @@ function EvidenceField({
|
||||
|
||||
// ---------------------------------------------------------------- sea service
|
||||
|
||||
/** Today as a `yyyy-mm-dd` key — same shape the pickers emit, so plain
|
||||
* string comparison is a valid date comparison. Taken in the authority's
|
||||
* timezone, matching the server's check, so a seafarer logging in from a
|
||||
* zone ahead of Addis isn't offered a day the server then rejects. */
|
||||
const todayKey = () =>
|
||||
new Date().toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
|
||||
|
||||
const EMPTY_SEA_SERVICE = {
|
||||
vesselName: '',
|
||||
imoNumber: '',
|
||||
@@ -276,12 +283,43 @@ function SeaServiceTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// Service already served — neither end of an engagement can be in the future.
|
||||
const today = todayKey();
|
||||
const dateError =
|
||||
form.engagementDate > today || form.dischargeDate > today
|
||||
? t('seaRecords.seaService.dateFuture', {
|
||||
defaultValue: 'Engagement and discharge dates cannot be in the future.',
|
||||
})
|
||||
: form.engagementDate &&
|
||||
form.dischargeDate &&
|
||||
form.engagementDate >= form.dischargeDate
|
||||
? t('seaRecords.seaService.dateOrder', {
|
||||
defaultValue: 'Discharge date must be after the engagement date.',
|
||||
})
|
||||
: 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 &&
|
||||
form.engagementDate &&
|
||||
form.dischargeDate &&
|
||||
form.engagementDate < form.dischargeDate;
|
||||
!dateError;
|
||||
|
||||
// Shown under the date pickers as they are filled: the seafarer sees what
|
||||
// the engagement is worth before saving it.
|
||||
@@ -369,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')}
|
||||
@@ -396,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
|
||||
@@ -408,6 +451,7 @@ function SeaServiceTab() {
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, engagementDate: val })
|
||||
}
|
||||
maxDate={form.dischargeDate || today}
|
||||
dateFormat="date"
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
@@ -417,24 +461,23 @@ function SeaServiceTab() {
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, dischargeDate: val })
|
||||
}
|
||||
minDate={form.engagementDate || undefined}
|
||||
maxDate={today}
|
||||
dateFormat="date"
|
||||
/>
|
||||
</Group>
|
||||
{form.engagementDate && form.dischargeDate && (
|
||||
{(dateError || (form.engagementDate && form.dischargeDate)) && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={formDays === null ? 'red' : 'teal'}
|
||||
color={dateError ? 'red' : 'teal'}
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
py={6}
|
||||
>
|
||||
{formDays === null
|
||||
? t('seaRecords.seaService.dateOrder', {
|
||||
defaultValue: 'Discharge date must be after the engagement date.',
|
||||
})
|
||||
: t('seaRecords.seaService.daysServed', {
|
||||
days: formDays,
|
||||
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
||||
})}
|
||||
{dateError ??
|
||||
t('seaRecords.seaService.daysServed', {
|
||||
days: formDays,
|
||||
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea
|
||||
@@ -581,10 +624,12 @@ function MedicalTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const today = todayKey();
|
||||
const valid =
|
||||
form.issuerName.trim().length > 1 &&
|
||||
form.issueDate &&
|
||||
form.expiryDate &&
|
||||
form.issueDate <= today &&
|
||||
form.issueDate < form.expiryDate;
|
||||
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||
@@ -664,6 +709,7 @@ function MedicalTab() {
|
||||
required
|
||||
value={form.issueDate}
|
||||
onChange={(val) => setForm({ ...form, issueDate: val })}
|
||||
maxDate={today}
|
||||
dateFormat="date"
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
@@ -671,6 +717,7 @@ function MedicalTab() {
|
||||
required
|
||||
value={form.expiryDate}
|
||||
onChange={(val) => setForm({ ...form, expiryDate: val })}
|
||||
minDate={form.issueDate || undefined}
|
||||
dateFormat="date"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
/** Columns for the applicant's in-flight vessel registration applications. */
|
||||
export function inFlightColumns(
|
||||
t: TFunction,
|
||||
deps: { onOpen: (app: LicenseApplication) => void },
|
||||
deps: {
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
onBypass: (app: LicenseApplication) => void;
|
||||
isPaying: boolean;
|
||||
bypassing: boolean;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication>[] {
|
||||
return [
|
||||
{
|
||||
@@ -38,15 +44,50 @@ export function inFlightColumns(
|
||||
},
|
||||
{
|
||||
header: t('applications.table.progress'),
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[row.original.status]}
|
||||
color={STATUS_COLORS[row.original.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
),
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const app = row.original;
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[app.status]}
|
||||
color={STATUS_COLORS[app.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
{/* The fee stops the registration dead, so the payment action sits
|
||||
on the bar rather than being hidden behind View. */}
|
||||
{app.status === 'PAYMENT_PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="yellow"
|
||||
loading={deps.isPaying}
|
||||
onClick={() => deps.onPay(app)}
|
||||
>
|
||||
{t('applications.actions.pay', {
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
{/* ponytail: shown unconditionally — the licensing page hides
|
||||
this behind the API's bypassEnabled capability flag, which
|
||||
is off here. Re-gate on capabilities before prod. */}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
loading={deps.bypassing}
|
||||
onClick={() => deps.onBypass(app)}
|
||||
title="Testing only — marks the fee paid"
|
||||
>
|
||||
{t('applications.actions.bypass')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
|
||||
@@ -30,11 +30,15 @@ import {
|
||||
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
|
||||
import { inFlightColumns } from '../inFlightColumns';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
TERMINAL_STATUSES,
|
||||
useApiMutation,
|
||||
useBypassPaymentMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -132,6 +136,8 @@ export function VesselRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [page, setPage] = useState(0);
|
||||
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||
@@ -155,9 +161,32 @@ export function VesselRegistrationPage() {
|
||||
!TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Payment bypassed',
|
||||
message: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Bypass failed',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns = inFlightColumns(t, {
|
||||
onOpen: (app) =>
|
||||
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
|
||||
// Paying leaves the SPA for Telebirr — a provider hand-off, not a route change.
|
||||
onPay: (app) => pay(app.id),
|
||||
onBypass: (app) => handleBypass(app.id),
|
||||
isPaying,
|
||||
bypassing,
|
||||
});
|
||||
|
||||
const certs = registration?.category === 'Sea-going Vessel (International)'
|
||||
|
||||
@@ -63,7 +63,7 @@ export const am: Translations = {
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
endorsements: 'የባህረኛ ማስተያየት',
|
||||
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||
documents: 'ሰነዶቼ',
|
||||
@@ -242,6 +242,7 @@ export const am: Translations = {
|
||||
RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል',
|
||||
INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ',
|
||||
INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል',
|
||||
INSPECTION_FAILED: 'ቁጥጥር አልተሳካም',
|
||||
APPROVED: 'ጸድቋል',
|
||||
REJECTED: 'ውድቅ ተደርጓል',
|
||||
ON_HOLD: 'ላይ ቆሟል',
|
||||
@@ -973,6 +974,11 @@ export const am: Translations = {
|
||||
appeal: 'ይግባኝ',
|
||||
retake: 'ድጋሚ · {{n}}',
|
||||
firstSitting: 'የመጀመሪያ ሙከራ',
|
||||
exam: 'ፈተና',
|
||||
completed: 'ተጠናቋል',
|
||||
timeExpired: 'ጊዜው አልቋል',
|
||||
resumeExam: 'ፈተና ይቀጥሉ',
|
||||
takeExam: 'ፈተና ይውሰዱ',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
@@ -1003,8 +1009,7 @@ export const am: Translations = {
|
||||
registered: "የተመዘገበ መርከበኛ ({{number}})",
|
||||
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
|
||||
},
|
||||
endorseCoc: "CoC ያረጋግጡ",
|
||||
endorseGoc: "GOC ያረጋግጡ",
|
||||
apply: "ለማስተያየት ያመልክቱ",
|
||||
registrationNotice: {
|
||||
prefix: "መጀመሪያ የ",
|
||||
link: "መርከበኛ ምዝገባዎን",
|
||||
|
||||
@@ -63,7 +63,7 @@ export const en = {
|
||||
certificates: 'Certificates',
|
||||
seamanBook: 'SeamanBook and BTC',
|
||||
btc: 'Basic Training Certificate',
|
||||
endorsements: 'Endorsements',
|
||||
endorsements: 'Endorsement Seafarer',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Transfers',
|
||||
documents: 'My Documents',
|
||||
@@ -242,6 +242,7 @@ export const en = {
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
INSPECTION_FAILED: 'Inspection Failed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
@@ -975,6 +976,11 @@ export const en = {
|
||||
appeal: 'Appeal',
|
||||
retake: 'Retake · {{n}}',
|
||||
firstSitting: 'First sitting',
|
||||
exam: 'Exam',
|
||||
completed: 'Completed',
|
||||
timeExpired: 'Time expired',
|
||||
resumeExam: 'Resume exam',
|
||||
takeExam: 'Take exam',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'Not called',
|
||||
PRESENT: 'Present',
|
||||
@@ -1005,8 +1011,7 @@ export const en = {
|
||||
registered: 'Registered seafarer ({{number}})',
|
||||
registrationRequired: 'Active seafarer registration required',
|
||||
},
|
||||
endorseCoc: 'Endorse a CoC',
|
||||
endorseGoc: 'Endorse a GOC',
|
||||
apply: 'Apply for an endorsement',
|
||||
registrationNotice: {
|
||||
prefix: 'Complete your',
|
||||
link: 'seafarer registration',
|
||||
|
||||
@@ -140,7 +140,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
},
|
||||
{
|
||||
to: "/endorsements",
|
||||
label: "Endorsements",
|
||||
label: "Endorsement Seafarer",
|
||||
i18nKey: "nav.endorsements",
|
||||
icon: IconRubberStamp,
|
||||
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||
|
||||
// Phase 1 pages
|
||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||
@@ -213,6 +214,14 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/exams/:examId/take",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.APPLY_EXAM, P.VIEW_OWN_EXAM]}>
|
||||
<ExamAttemptPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
// The public-facing registry was a hardcoded mock and does not belong in
|
||||
// the applicant portal; officers browse seafarers in the backoffice.
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user