- {block.variable ? `{{${block.variable}}}` : block.text || ' '}
+ {isImage ? (
+
+ ) : block.variable ? (
+ `{{${block.variable}}}`
+ ) : (
+ block.text || ' '
+ )}
{isSelected && !disabled && (
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' ? : undefined
+ }
onClick={() =>
- canvasMode ? onAddBlock(variable.key) : onInsert(variable.key)
+ canvasMode
+ ? onAddBlock(variable.key, variable.kind === 'image' ? 'image' : 'text')
+ : onInsert(variable.key)
}
>
{`{{${variable.key}}}`}
diff --git a/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts b/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts
index 9488bcd2f..e21bd7a17 100644
--- a/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts
+++ b/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts
@@ -41,14 +41,43 @@ function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string {
return `
\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 `
\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 ` ${content}
\n`;
}
diff --git a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts
index 19a28fe34..14abcd909 100644
--- a/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts
+++ b/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts
@@ -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 `
`) โ 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) =>
diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
index d30aa5d2f..61d3f5ef9 100644
--- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
@@ -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(null);
+ const [rankId, setRankId] = useState(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')}
/>
@@ -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();
diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx
index 8b3ead1fc..b1192fca2 100644
--- a/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx
+++ b/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx
@@ -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;
+
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 (
+
+ c.path)}
+ value={value.field ?? ''}
+ onChange={setField}
+ />
+
+
+
+
+ {operator === 'in' && (value.in?.length ?? 0) > 0 && (
+
+ {(value.in ?? []).map((v, i) => (
+ setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
+ title={t('certReq.condition.removeValue', 'Click to remove')}
+ >
+ {String(v)} ร
+
+ ))}
+
+ )}
+
+ {!target && value.field && (
+
+ {t(
+ 'certReq.condition.unknownField',
+ 'This path is not a field in the current schema yet โ it will still be saved as typed.',
+ )}
+
+ )}
+
+ );
+}
+
+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 && (
- c.path)}
- value={value?.field ?? ''}
- onChange={setField}
+ checked={isAnyOf}
+ onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
/>
-
- ({ value: op, label: op }))}
- value={operator}
- onChange={(v) => v && setOperator(v as Operator)}
- allowDeselect={false}
- />
-
- {operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
- ({
- 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' ? (
- setValueRaw(String(e.currentTarget.checked))}
- />
- ) : (
- setValueRaw(e.currentTarget.value)}
- />
- )
- )}
-
- {operator === 'in' && target?.field.type === 'SELECT' && (
- ({
- 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' && (
-
- setInValues(
- e.currentTarget.value
- .split(',')
- .map((s) => s.trim())
- .filter(Boolean),
- )
- }
- />
- )}
-
-
- {operator === 'in' && (value?.in?.length ?? 0) > 0 && (
-
- {(value?.in ?? []).map((v, i) => (
- setInValues((value?.in ?? []).filter((_, idx) => idx !== i).map(String))}
- title={t('certReq.condition.removeValue', 'Click to remove')}
- >
- {String(v)} ร
-
+ {isAnyOf ? (
+
+ {arms.map((arm, i) => (
+
+
+
+ {t('certReq.condition.anyOfArm', 'Condition {{n}}', { n: i + 1 })}
+
+ removeArm(i)}
+ >
+
+
+
+ setArm(i, next)}
+ targets={targets}
+ palette={palette}
+ />
+
))}
-
- )}
-
- {!target && value?.field && (
-
- {t(
- 'certReq.condition.unknownField',
- 'This path is not a field in the current schema yet โ it will still be saved as typed.',
- )}
-
+
+
+
+
+
+ {t('certReq.condition.anyOfAdd', 'Add another field')}
+
+
+
+ ) : (
+ onChange(next)}
+ targets={targets}
+ palette={palette}
+ />
)}
)}
diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
index 733334b8e..5baba8e99 100644
--- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
+++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
@@ -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;
}
diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx
index 1c2510611..05680009d 100644
--- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx
+++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx
@@ -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
key: {req.key} ยท {req.maxSizeMb}MB ยท {req.allowedMimeTypes.join(', ')}
- {req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
+ {req.mode === 'CONDITIONAL' && req.conditionExpression && (
- {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)}
)}
diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx
index e3f9ed214..323703738 100644
--- a/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx
+++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx
@@ -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 && (