mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
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';
|
||||
|
||||
@@ -233,6 +234,7 @@ export function TemplateCanvas({
|
||||
|
||||
{placements.map((block) => {
|
||||
const isSelected = block.id === selectedId;
|
||||
const isImage = block.type === 'image';
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
@@ -247,6 +249,7 @@ export function TemplateCanvas({
|
||||
width: `${block.widthPct}%`,
|
||||
fontSize: block.fontSize ?? 14,
|
||||
fontWeight: block.fontWeight ?? 'normal',
|
||||
fontStyle: block.fontStyle ?? 'normal',
|
||||
textAlign: block.align ?? 'left',
|
||||
color: block.color ?? '#111111',
|
||||
cursor: disabled ? 'default' : 'move',
|
||||
@@ -259,9 +262,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`;
|
||||
}
|
||||
|
||||
@@ -81,23 +81,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}
|
||||
@@ -353,7 +392,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>
|
||||
@@ -369,6 +408,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,10 @@ export function DocumentRequirementEditorDrawer({
|
||||
return;
|
||||
}
|
||||
if (!draft.name.en?.trim()) return;
|
||||
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) {
|
||||
const hasCondition =
|
||||
Boolean(draft.conditionExpression?.field) ||
|
||||
Boolean(draft.conditionExpression?.anyOf?.length);
|
||||
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
|
||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,23 +4,26 @@ import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useGetRanksQuery, useLocalized } from '@ema-platform/api';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
useUpdateCertificationMutation,
|
||||
useDeleteCertificationMutation,
|
||||
} from '../../api/certification-api';
|
||||
import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
|
||||
import { type Certification } from '../../types/certification';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationActionsColumn } from './actions';
|
||||
|
||||
function CertificationForm({
|
||||
editing,
|
||||
rankOptions,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
editing: Certification | null;
|
||||
rankOptions: { value: string; label: string }[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
@@ -53,7 +56,7 @@ function CertificationForm({
|
||||
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
|
||||
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
|
||||
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
|
||||
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
|
||||
data={rankOptions}
|
||||
value={rankKey}
|
||||
onChange={setRankKey}
|
||||
size="sm"
|
||||
@@ -74,7 +77,10 @@ export function CertificationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const localized = useLocalized();
|
||||
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||
const { data: rankRes } = useGetRanksQuery();
|
||||
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
@@ -154,6 +160,7 @@ export function CertificationPage() {
|
||||
{showForm && (
|
||||
<CertificationForm
|
||||
editing={editing}
|
||||
rankOptions={rankOptions}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={resetForm}
|
||||
|
||||
@@ -3,24 +3,6 @@ export interface LocalePair {
|
||||
am: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* STCW rank an exam certification is for — the join that lets the
|
||||
* schedule-exam picker offer only sittings valid for an application's rank.
|
||||
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
|
||||
* `proficiency` on the backend. Not every certification is on the examined
|
||||
* ladder, so this stays a plain optional string rather than a required enum.
|
||||
*/
|
||||
export const RANK_KEY_OPTIONS = [
|
||||
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
|
||||
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
|
||||
{ value: 'MASTER', label: 'Master' },
|
||||
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
|
||||
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
|
||||
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
|
||||
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
|
||||
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
|
||||
] as const;
|
||||
|
||||
export interface Certification {
|
||||
id: string;
|
||||
name: LocalePair;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
@@ -248,6 +249,7 @@ 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}
|
||||
@@ -362,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();
|
||||
@@ -383,9 +388,18 @@ export function ExamPage() {
|
||||
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] ?? "-";
|
||||
|
||||
|
||||
@@ -3,17 +3,19 @@ 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 {
|
||||
@@ -39,7 +41,7 @@ export interface Exam {
|
||||
date: string;
|
||||
givenTime: EstimatedTime | null;
|
||||
type: ExamType;
|
||||
form: QuestionForm;
|
||||
form: ExamForm;
|
||||
venue: string;
|
||||
administrationMethod: ExamAdministrationMethod;
|
||||
evaluationMethod: ExamEvaluationMethod;
|
||||
@@ -63,7 +65,7 @@ export interface CreateExamPayload {
|
||||
date: string;
|
||||
givenTime: EstimatedTime;
|
||||
type: ExamType;
|
||||
form: QuestionForm;
|
||||
form: ExamForm;
|
||||
venue: string;
|
||||
administrationMethod: ExamAdministrationMethod;
|
||||
evaluationMethod: ExamEvaluationMethod;
|
||||
@@ -79,7 +81,7 @@ export interface UpdateExamPayload {
|
||||
date?: string;
|
||||
givenTime?: EstimatedTime;
|
||||
type?: ExamType;
|
||||
form?: QuestionForm;
|
||||
form?: ExamForm;
|
||||
venue?: string;
|
||||
administrationMethod?: ExamAdministrationMethod;
|
||||
evaluationMethod?: ExamEvaluationMethod;
|
||||
@@ -101,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;
|
||||
@@ -127,12 +124,14 @@ export interface ExamRegistration {
|
||||
seafarerNumber: string | null;
|
||||
};
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||
attempt?: {
|
||||
id: string;
|
||||
status: "IN_PROGRESS" | "SUBMITTED" | "EXPIRED";
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type RegradeOutcome =
|
||||
| { graded: true; resultId: string }
|
||||
| { graded: false; reason: string };
|
||||
{ graded: true; resultId: string } | { graded: false; reason: string };
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
@@ -141,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;
|
||||
@@ -176,6 +168,6 @@ export interface CreateIncidentPayload {
|
||||
|
||||
export interface ResolveIncidentPayload {
|
||||
incidentId: string;
|
||||
outcome: 'RESOLVED' | 'DISMISSED';
|
||||
outcome: "RESOLVED" | "DISMISSED";
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
@@ -11,11 +11,7 @@ interface Props {
|
||||
applicantName: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (payload: {
|
||||
examId: string;
|
||||
admissionNumber?: string;
|
||||
examDate?: string;
|
||||
}) => void;
|
||||
onConfirm: (payload: { examId: string; examDate?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +34,6 @@ export function ScheduleExamModal({
|
||||
const { t } = useTranslation();
|
||||
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
|
||||
const [examId, setExamId] = useState<string | null>(null);
|
||||
const [admissionNumber, setAdmissionNumber] = useState('');
|
||||
|
||||
const options = (exams ?? []).map((exam) => ({
|
||||
value: exam.id,
|
||||
@@ -52,7 +47,6 @@ export function ScheduleExamModal({
|
||||
if (!examId) return;
|
||||
onConfirm({
|
||||
examId,
|
||||
admissionNumber: admissionNumber.trim() || undefined,
|
||||
examDate: selected?.date ? String(selected.date) : undefined,
|
||||
});
|
||||
}
|
||||
@@ -92,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}>
|
||||
|
||||
@@ -65,16 +65,9 @@ export interface ActionDefinition {
|
||||
*/
|
||||
export const ACTIONS: ActionDefinition[] = [
|
||||
// ------------------------------------------------------------- workflow
|
||||
{
|
||||
id: 'claim',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.claim',
|
||||
// Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP)
|
||||
// sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED.
|
||||
from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
|
||||
permissions: ['can:claim:license-application'],
|
||||
emphasis: 'light',
|
||||
},
|
||||
// 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',
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -257,6 +257,7 @@ export const am: Translations = {
|
||||
oral: "ቃል",
|
||||
essay: "ኢሴይ",
|
||||
choice: "ምርጫ",
|
||||
both: "ሁለቱም",
|
||||
offline: "ከመስመር ውጪ",
|
||||
online: "በመስመር",
|
||||
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
||||
@@ -309,6 +310,7 @@ export const am: Translations = {
|
||||
formType: {
|
||||
ESSAY: "ኢሴይ",
|
||||
CHOICE: "ምርጫ",
|
||||
BOTH: "ሁለቱም",
|
||||
},
|
||||
admin: {
|
||||
OFFLINE: "ከመስመር ውጪ",
|
||||
@@ -1007,7 +1009,10 @@ export const am: Translations = {
|
||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||
reject: "አትቀበል",
|
||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||
recordExamOutcome: "የፈተና ውጤት መዝግብ",
|
||||
confirmPayment: "ክፍያ አረጋግጥ",
|
||||
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
|
||||
issueCertificate: "ሰርተፍኬት ስጥ",
|
||||
print: "ሰነድ አትም",
|
||||
copyLink: "አገናኝ ቅዳ",
|
||||
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",
|
||||
|
||||
@@ -256,6 +256,7 @@ 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.',
|
||||
@@ -307,6 +308,7 @@ export const en = {
|
||||
formType: {
|
||||
ESSAY: 'Essay',
|
||||
CHOICE: 'Choice',
|
||||
BOTH: 'Both',
|
||||
},
|
||||
admin: {
|
||||
OFFLINE: 'Offline',
|
||||
@@ -1016,7 +1018,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',
|
||||
|
||||
Reference in New Issue
Block a user