feat: add document requirements and form schema management for license types

- Implement DocumentRequirementsTab for managing document upload requirements.
- Create FieldEditorDrawer and SectionEditorDrawer for editing fields and sections in the form schema.
- Develop FormSchemaTab to handle sections and fields for a license type's form schema.
- Introduce CertificateRequirementsPage to serve as the main interface for configuring license type requirements.
- Add hooks for requirement actions to streamline mutation handling and notifications.
- Implement condition handling for fields and sections to manage visibility based on user input.
- Create schema-paths configuration to facilitate condition target collection.
This commit is contained in:
Nati
2026-08-20 13:37:26 +00:00
parent 2ec857f0f0
commit 357dc3e4d5
15 changed files with 1845 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
import { useLocalized } from '@ema-platform/api';
import type { ConditionTarget } from '../config/schema-paths';
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
function operatorOf(condition: ConditionValue | undefined): Operator | null {
if (!condition) return null;
if (condition.isSet !== undefined) return 'isSet';
if (condition.equals !== undefined) return 'equals';
if (condition.notEquals !== undefined) return 'notEquals';
if (condition.in !== undefined) return 'in';
return null;
}
/** Best-effort type for a raw stored value, so re-editing an existing
* condition renders a number input for a numeric value rather than text. */
function coerce(raw: string, targetType: string | undefined): string | number | boolean {
if (targetType === 'BOOLEAN') return raw === 'true';
if (targetType === 'NUMBER' || targetType === 'MONEY') {
const n = Number(raw);
return Number.isFinite(n) && raw.trim() !== '' ? n : raw;
}
return raw;
}
/**
* Authors one `FieldCondition` (`showWhen` on a section/field, or
* `conditionExpression` on a document requirement).
*
* The field-path box autocompletes from every field already defined in this
* licence type's schema (`targets`); when the chosen path resolves to a
* 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.
*/
export function ConditionBuilder({
value,
onChange,
targets,
palette,
allowClear = true,
}: {
value: ConditionValue | null;
onChange: (value: ConditionValue | null) => void;
targets: ConditionTarget[];
palette: FormSchemaPalette | undefined;
/** Hide the "no condition" toggle — used where a condition is mandatory (CONDITIONAL document mode). */
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'];
function setField(field: string) {
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
}
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 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">
{allowClear && (
<Switch
label={t('certReq.condition.enable', 'Only apply when a condition holds')}
checked={active}
onChange={(e) => onChange(e.currentTarget.checked ? { field: '', equals: '' } : null)}
/>
)}
{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',
)}
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>
)}
</Stack>
);
}

View File

@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react';
import {
Button,
Checkbox,
Divider,
Drawer,
MultiSelect,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import type { ApplicationKind, DocumentRequirement, FormSchemaPalette } from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
const MIME_OPTIONS = [
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image/jpeg', label: 'JPEG' },
{ value: 'image/png', label: 'PNG' },
];
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
function emptyDraft(applicationKind: ApplicationKind): DraftRequirement {
return {
key: '',
name: { en: '', am: '' },
applicationKind,
mode: 'ALWAYS',
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
maxSizeMb: 5,
requiresValidityDates: false,
allowMultiple: false,
sortOrder: 0,
};
}
/** Adds/edits one document requirement slot for a licence type + application kind. */
export function DocumentRequirementEditorDrawer({
opened,
onClose,
requirement,
defaultApplicationKind,
onSave,
palette,
conditionTargets,
saving,
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new requirement. */
requirement: DocumentRequirement | null;
defaultApplicationKind: ApplicationKind;
onSave: (draft: DraftRequirement) => void;
palette: FormSchemaPalette | undefined;
conditionTargets: ConditionTarget[];
saving: boolean;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<DraftRequirement>(emptyDraft(defaultApplicationKind));
const [keyError, setKeyError] = useState<string | null>(null);
const isNew = !requirement;
useEffect(() => {
if (opened) {
setDraft(
requirement
? {
key: requirement.key,
name: { ...requirement.name },
description: requirement.description ? { ...requirement.description } : undefined,
applicationKind: requirement.applicationKind,
mode: requirement.mode,
conditionExpression: requirement.conditionExpression,
allowedMimeTypes: requirement.allowedMimeTypes,
maxSizeMb: requirement.maxSizeMb,
requiresValidityDates: requirement.requiresValidityDates,
allowMultiple: requirement.allowMultiple,
sortOrder: requirement.sortOrder,
}
: emptyDraft(defaultApplicationKind),
);
setKeyError(null);
}
}, [opened, requirement, defaultApplicationKind]);
function save() {
if (!draft.key.trim()) {
setKeyError(t('certReq.doc.keyRequired', 'Key is required'));
return;
}
if (!draft.name.en?.trim()) return;
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) {
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
return;
}
onSave({
...draft,
key: draft.key.trim(),
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
});
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={<Text fw={700}>{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}</Text>}
>
<Stack gap="md">
<TextInput
label={t('certReq.doc.key', 'Key')}
placeholder="bank_letter"
required
value={draft.key}
error={keyError}
disabled={!isNew}
description={isNew ? t('certReq.doc.keyHelp', 'Stable slug identifying this document slot') : t('certReq.doc.keyLocked', 'Key cannot change once created')}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
/>
<BilingualInput
label={t('certReq.doc.name', 'Name')}
required
value={{ en: draft.name.en ?? '', am: draft.name.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
/>
<BilingualInput
label={t('certReq.doc.description', 'Description')}
value={{ en: draft.description?.en ?? '', am: draft.description?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
/>
<Select
label={t('certReq.doc.applicationKind', 'Application kind')}
data={[
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
]}
value={draft.applicationKind}
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
allowDeselect={false}
disabled={!isNew}
description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined}
/>
<Select
label={t('certReq.doc.mode', 'Mode')}
data={[
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
]}
value={draft.mode}
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
allowDeselect={false}
/>
{draft.mode === 'CONDITIONAL' && (
<>
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
<ConditionBuilder
value={(draft.conditionExpression ?? null) as ConditionValue | null}
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
targets={conditionTargets}
palette={palette}
allowClear={false}
/>
</>
)}
<MultiSelect
label={t('certReq.doc.allowedTypes', 'Allowed file types')}
data={MIME_OPTIONS}
value={draft.allowedMimeTypes}
onChange={(v) => setDraft((d) => ({ ...d, allowedMimeTypes: v }))}
/>
<NumberInput
label={t('certReq.doc.maxSize', 'Max file size (MB)')}
min={1}
value={draft.maxSizeMb}
onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))}
/>
<Checkbox
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
checked={draft.requiresValidityDates}
onChange={(e) => setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))}
/>
<Checkbox
label={t('certReq.doc.allowMultiple', 'Allow multiple uploads')}
checked={draft.allowMultiple}
onChange={(e) => setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))}
/>
<NumberInput
label={t('certReq.doc.sortOrder', 'Sort order')}
value={draft.sortOrder}
onChange={(v) => setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : d.sortOrder }))}
/>
<ModalFooter>
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="teal" loading={saving} onClick={save}>
{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.saveChanges', 'Save changes')}
</Button>
</ModalFooter>
</Stack>
</Drawer>
);
}

View File

@@ -0,0 +1,203 @@
import { useMemo, useState } from 'react';
import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core';
import { IconAlertCircle, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
import {
extractErrorMessage,
useCreateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetDocumentRequirementsQuery,
useGetFormSchemaPaletteQuery,
useLocalized,
useUpdateDocumentRequirementMutation,
type ApplicationKind,
type DocumentRequirement,
type LicenseType,
} from '@ema-platform/api';
import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
const MODE_COLOR: Record<DocumentRequirement['mode'], string> = {
ALWAYS: 'blue',
CONDITIONAL: 'violet',
OPTIONAL: 'gray',
};
/**
* Document upload requirements for one licence type, grouped by application
* kind (a new-application slot and its renewal counterpart are different
* rows even when they share a key). Every edit is a real CRUD call the
* moment the admin confirms it — there is no separate "save all" step here,
* unlike the form schema tab's whole-document replace.
*/
export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseType }) {
const { t } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data, isLoading, isError, error, refetch } = useGetDocumentRequirementsQuery();
const { data: palette } = useGetFormSchemaPaletteQuery();
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
const requirements = useMemo(
() => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id),
[data, licenseType.id],
);
const conditionTargets = collectConditionTargets(licenseType.formSchema.sections);
async function handleSave(draft: Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>) {
const ok = await run(
() =>
editorState?.requirement
? updateRequirement({ id: editorState.requirement.id, ...draft }).unwrap()
: createRequirement({ ...draft, licenseTypeId: licenseType.id }).unwrap(),
editorState?.requirement
? t('certReq.doc.updated', 'Document requirement updated')
: t('certReq.doc.created', 'Document requirement added'),
);
if (ok) setEditorState(null);
}
async function confirmDelete() {
if (!deleteTarget) return;
const ok = await run(
() => deleteRequirement(deleteTarget.id).unwrap(),
t('certReq.doc.deleted', 'Document requirement removed'),
);
if (ok) setDeleteTarget(null);
}
if (isLoading) return <PageLoader label={t('certReq.doc.loading', 'Loading document requirements…')} height={300} />;
if (isError) {
return (
<ErrorState
title={t('certReq.doc.loadFailed', 'Could not load document requirements')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
icon={IconAlertCircle}
/>
);
}
return (
<Stack gap="lg">
<Text fz="sm" c="dimmed">
{t(
'certReq.doc.subtitle',
'What an applicant must upload for this licence type, split by new application and renewal.',
)}
</Text>
{KINDS.map((kind) => {
const rows = requirements
.filter((r) => r.applicationKind === kind)
.sort((a, b) => a.sortOrder - b.sortOrder);
return (
<Card key={kind} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Title order={5}>
{kind === 'NEW' ? t('certReq.doc.kindNew', 'New application') : t('certReq.doc.kindRenewal', 'Renewal')}
</Title>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => setEditorState({ kind, requirement: null })}
>
{t('certReq.doc.add', 'Add document requirement')}
</Button>
</Group>
{rows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
{t('certReq.doc.emptyKind', 'No document requirements for this application kind yet.')}
</Text>
) : (
<Stack gap="xs">
{rows.map((req) => (
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Group gap={6}>
<Text fz="sm" fw={600} truncate>{localized(req.name) || req.key}</Text>
<Badge size="xs" color={MODE_COLOR[req.mode]} variant="light">{req.mode}</Badge>
{req.allowMultiple && <Badge size="xs" variant="outline">{t('certReq.doc.multiple', 'multiple')}</Badge>}
</Group>
<Text fz="xs" c="dimmed" truncate>
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
</Text>
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
<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'))}
</Text>
)}
</div>
<Group gap={4} wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => setEditorState({ kind, requirement: req })}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(req)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
))}
</Stack>
)}
</Card>
);
})}
{requirements.length === 0 && (
<EmptyState
title={t('certReq.doc.empty', 'No document requirements configured')}
description={t('certReq.doc.emptyBody', 'Add the documents an applicant must upload for this licence type.')}
/>
)}
<DocumentRequirementEditorDrawer
opened={editorState !== null}
onClose={() => setEditorState(null)}
requirement={editorState?.requirement ?? null}
defaultApplicationKind={editorState?.kind ?? 'NEW'}
onSave={handleSave}
palette={palette}
conditionTargets={conditionTargets}
saving={creating || updating}
/>
<Modal opened={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm">
<Stack gap="md">
<Alert color="yellow" variant="light">
{t('certReq.doc.deleteWarning', 'Applicants already relying on this slot will no longer see it. This cannot be undone.')}
</Alert>
<Text fz="sm">
{t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', {
name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.key : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="red" onClick={confirmDelete}>{t('certReq.delete', 'Delete')}</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,224 @@
import { useEffect, useState } from 'react';
import {
Button,
Checkbox,
Divider,
Drawer,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import type { FormFieldConfig, FormSchemaPalette } from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
function emptyField(): FormFieldConfig {
return { key: '', label: { en: '', am: '' }, type: 'TEXT' };
}
/** Adds/edits one field within a section. Options only show for SELECT. */
export function FieldEditorDrawer({
opened,
onClose,
field,
onSave,
palette,
conditionTargets,
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new field. */
field: FormFieldConfig | null;
onSave: (field: FormFieldConfig) => void;
palette: FormSchemaPalette | undefined;
conditionTargets: ConditionTarget[];
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<FormFieldConfig>(emptyField());
const [keyError, setKeyError] = useState<string | null>(null);
useEffect(() => {
if (opened) {
setDraft(field ? { ...field, label: { ...field.label } } : emptyField());
setKeyError(null);
}
}, [opened, field]);
const typeInfo = palette?.fieldTypes.find((f) => f.type === draft.type);
const isNew = !field;
function save() {
if (!draft.key.trim() || !KEY_PATTERN.test(draft.key.trim())) {
setKeyError(t('certReq.field.keyInvalid', 'Key must start with a letter and contain only letters, numbers, underscores'));
return;
}
if (!draft.label.en?.trim()) {
setKeyError(null);
return;
}
onSave({
...draft,
key: draft.key.trim(),
options: typeInfo?.supportsOptions ? draft.options : undefined,
min: typeInfo?.supportsRange ? draft.min : undefined,
max: typeInfo?.supportsRange ? draft.max : undefined,
maxLength: typeInfo?.supportsMaxLength ? draft.maxLength : undefined,
});
}
function addOption() {
setDraft((d) => ({
...d,
options: [...(d.options ?? []), { value: '', label: { en: '', am: '' } }],
}));
}
function updateOption(index: number, patch: Partial<{ value: string; label: { en: string; am: string } }>) {
setDraft((d) => ({
...d,
options: (d.options ?? []).map((o, i) => (i === index ? { ...o, ...patch } : o)),
}));
}
function removeOption(index: number) {
setDraft((d) => ({ ...d, options: (d.options ?? []).filter((_, i) => i !== index) }));
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={<Text fw={700}>{isNew ? t('certReq.field.add', 'Add field') : t('certReq.field.edit', 'Edit field')}</Text>}
>
<Stack gap="md">
<TextInput
label={t('certReq.field.key', 'Field key')}
placeholder="rank"
required
value={draft.key}
error={keyError}
disabled={!isNew}
description={
isNew
? t('certReq.field.keyHelp', 'Letters, numbers and underscores only — becomes the form data key')
: t('certReq.field.keyLocked', 'Key cannot change once created')
}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
/>
<BilingualInput
label={t('certReq.field.label', 'Label')}
required
value={{ en: draft.label.en ?? '', am: draft.label.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, label: v }))}
/>
<Select
label={t('certReq.field.type', 'Field type')}
data={(palette?.fieldTypes ?? []).map((f) => ({ value: f.type, label: f.type }))}
value={draft.type}
onChange={(v) => v && setDraft((d) => ({ ...d, type: v as FormFieldConfig['type'] }))}
allowDeselect={false}
/>
<Checkbox
label={t('certReq.field.required', 'Required')}
checked={Boolean(draft.required)}
onChange={(e) => setDraft((d) => ({ ...d, required: e.currentTarget.checked }))}
/>
<BilingualInput
label={t('certReq.field.placeholder', 'Placeholder')}
value={{ en: draft.placeholder?.en ?? '', am: draft.placeholder?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, placeholder: v }))}
/>
<BilingualInput
label={t('certReq.field.helpText', 'Help text')}
value={{ en: draft.helpText?.en ?? '', am: draft.helpText?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, helpText: v }))}
/>
{typeInfo?.supportsRange && (
<Group grow>
<NumberInput
label={t('certReq.field.min', 'Minimum')}
value={draft.min ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, min: typeof v === 'number' ? v : undefined }))}
/>
<NumberInput
label={t('certReq.field.max', 'Maximum')}
value={draft.max ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, max: typeof v === 'number' ? v : undefined }))}
/>
</Group>
)}
{typeInfo?.supportsMaxLength && (
<NumberInput
label={t('certReq.field.maxLength', 'Max length')}
value={draft.maxLength ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, maxLength: typeof v === 'number' ? v : undefined }))}
/>
)}
{typeInfo?.supportsOptions && (
<Stack gap="xs">
<Group justify="space-between">
<Text fz="sm" fw={600}>{t('certReq.field.options', 'Options')}</Text>
<Button size="xs" variant="light" leftSection={<IconPlus size={13} />} onClick={addOption}>
{t('certReq.field.addOption', 'Add option')}
</Button>
</Group>
{(draft.options ?? []).map((o, i) => (
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
<TextInput
size="xs"
label={i === 0 ? t('certReq.field.optionValue', 'Value') : undefined}
value={o.value}
onChange={(e) => updateOption(i, { value: e.currentTarget.value })}
style={{ flex: 1 }}
/>
<BilingualInput
size="xs"
label={i === 0 ? t('certReq.field.optionLabel', 'Label') : undefined}
value={{ en: o.label.en ?? '', am: o.label.am ?? '' }}
onChange={(v) => updateOption(i, { label: v })}
style={{ flex: 2 }}
/>
<Button size="xs" color="red" variant="subtle" px={6} onClick={() => removeOption(i)}>
<IconTrash size={14} />
</Button>
</Group>
))}
</Stack>
)}
<Divider label={t('certReq.condition.title', 'Visibility condition')} labelPosition="left" />
<ConditionBuilder
value={(draft.showWhen ?? null) as ConditionValue | null}
onChange={(v) => setDraft((d) => ({ ...d, showWhen: v ?? undefined }))}
targets={conditionTargets}
palette={palette}
/>
<ModalFooter>
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="teal" onClick={save}>
{isNew ? t('certReq.field.add', 'Add field') : t('certReq.saveChanges', 'Save changes')}
</Button>
</ModalFooter>
</Stack>
</Drawer>
);
}

View File

@@ -0,0 +1,334 @@
import { useEffect, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Group,
Modal,
Stack,
Text,
} from '@mantine/core';
import {
IconAlertTriangle,
IconChevronDown,
IconChevronUp,
IconEdit,
IconGripVertical,
IconPlus,
IconTrash,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { EmptyState, ModalFooter, notify } from '@ema-platform/ui';
import {
extractErrorMessage,
useGetFormSchemaPaletteQuery,
useLocalized,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
type FormFieldConfig,
type FormSectionConfig,
type LicenseType,
type SchemaIssue,
} from '@ema-platform/api';
import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { FieldEditorDrawer } from './FieldEditorDrawer';
import { SectionEditorDrawer } from './SectionEditorDrawer';
function moveItem<T>(list: T[], index: number, direction: -1 | 1): T[] {
const target = index + direction;
if (target < 0 || target >= list.length) return list;
const next = [...list];
[next[index], next[target]] = [next[target], next[index]];
return next.map((item, i) => ({ ...item, sortOrder: i } as T));
}
/**
* Sections/fields editor for one licence type's `formSchema`.
*
* Edits build up a local draft; nothing is sent until "Save schema" — the
* server replaces the whole `formSchema` in one `PUT`, so partial saves would
* not match what the API accepts anyway. "Check for errors" dry-runs the same
* lint the save uses, so an author can fix problems before committing.
*/
export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
const { t } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data: palette } = useGetFormSchemaPaletteQuery();
const [saveSchema, { isLoading: saving }] = useUpdateFormSchemaMutation();
const [validateSchema, { isLoading: validating }] = useValidateFormSchemaMutation();
const [sections, setSections] = useState<FormSectionConfig[]>(licenseType.formSchema.sections);
const [issues, setIssues] = useState<SchemaIssue[] | null>(null);
const [dirty, setDirty] = useState(false);
// A newly selected licence type replaces the draft outright. Deliberately
// keyed on the id alone: a background refetch of the *same* type (e.g. the
// list tag invalidation right after this tab's own save) must not clobber
// whatever the admin is mid-editing.
useEffect(() => {
setSections(licenseType.formSchema.sections);
setIssues(null);
setDirty(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [licenseType.id]);
const [sectionDrawer, setSectionDrawer] = useState<{ section: FormSectionConfig | null } | null>(null);
const [fieldDrawer, setFieldDrawer] = useState<{ sectionKey: string; field: FormFieldConfig | null } | null>(null);
const [deleteSection, setDeleteSection] = useState<FormSectionConfig | null>(null);
const [deleteField, setDeleteField] = useState<{ sectionKey: string; field: FormFieldConfig } | null>(null);
const conditionTargets = collectConditionTargets(sections);
function mutate(next: FormSectionConfig[]) {
setSections(next);
setDirty(true);
setIssues(null);
}
function saveSection(meta: Omit<FormSectionConfig, 'fields'>) {
const editing = sectionDrawer?.section;
if (editing) {
mutate(sections.map((s) => (s.key === editing.key ? { ...s, ...meta } : s)));
} else {
mutate([...sections, { ...meta, fields: [] }]);
}
setSectionDrawer(null);
}
function saveField(field: FormFieldConfig) {
if (!fieldDrawer) return;
mutate(
sections.map((s) => {
if (s.key !== fieldDrawer.sectionKey) return s;
const exists = fieldDrawer.field;
return {
...s,
fields: exists
? s.fields.map((f) => (f.key === exists.key ? field : f))
: [...s.fields, field],
};
}),
);
setFieldDrawer(null);
}
function confirmDeleteSection() {
if (!deleteSection) return;
mutate(sections.filter((s) => s.key !== deleteSection.key));
setDeleteSection(null);
}
function confirmDeleteField() {
if (!deleteField) return;
mutate(
sections.map((s) =>
s.key === deleteField.sectionKey
? { ...s, fields: s.fields.filter((f) => f.key !== deleteField.field.key) }
: s,
),
);
setDeleteField(null);
}
async function checkForErrors() {
try {
const result = await validateSchema({
formSchema: { sections },
licenseTypeId: licenseType.id,
}).unwrap();
setIssues(result.issues);
if (result.valid) notify.success(t('certReq.schema.noIssues', 'No issues found'));
} catch (err) {
notify.error(extractErrorMessage(err));
}
}
async function handleSave() {
const ok = await run(
() => saveSchema({ id: licenseType.id, formSchema: { sections } }).unwrap(),
t('certReq.schema.saved', 'Form schema saved'),
);
if (ok) {
setDirty(false);
setIssues(null);
}
}
return (
<Stack gap="md">
<Group justify="space-between">
<Text fz="sm" c="dimmed">
{t(
'certReq.schema.subtitle',
'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.',
)}
</Text>
<Group gap="sm">
<Button variant="default" loading={validating} onClick={checkForErrors}>
{t('certReq.schema.checkErrors', 'Check for errors')}
</Button>
<Button
leftSection={<IconPlus size={15} />}
variant="default"
onClick={() => setSectionDrawer({ section: null })}
>
{t('certReq.section.add', 'Add section')}
</Button>
<Button color="teal" loading={saving} disabled={!dirty} onClick={handleSave}>
{t('certReq.schema.save', 'Save schema')}
</Button>
</Group>
</Group>
{issues !== null && issues.length > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t('certReq.schema.issuesFound', 'Issues found')}>
<Stack gap={4}>
{issues.map((issue, i) => (
<Text key={i} fz="xs">
<Text span fw={600}>{issue.path}</Text>: {issue.message}
</Text>
))}
</Stack>
</Alert>
)}
{sections.length === 0 ? (
<EmptyState
title={t('certReq.schema.empty', 'No sections yet')}
description={t('certReq.schema.emptyBody', 'Add a section to start building this licence type\'s form.')}
action={{ label: t('certReq.section.add', 'Add section'), onClick: () => setSectionDrawer({ section: null }) }}
/>
) : (
<Stack gap="md">
{sections.map((section, sIndex) => (
<Card key={section.key} withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" mb="sm">
<Group gap="xs" wrap="nowrap">
<IconGripVertical size={16} color="var(--mantine-color-gray-5)" />
<div>
<Group gap="xs">
<Text fw={700}>{localized(section.title) || section.key}</Text>
{section.group && <Badge size="xs" variant="light">{t('certReq.section.groupBadge', 'group')}: {section.group}</Badge>}
{section.showWhen && <Badge size="xs" color="violet" variant="light">{t('certReq.condition.badge', 'conditional')}</Badge>}
</Group>
<Text fz="xs" c="dimmed">key: {section.key}</Text>
</div>
</Group>
<Group gap={4}>
<ActionIcon variant="subtle" disabled={sIndex === 0} onClick={() => mutate(moveItem(sections, sIndex, -1))}>
<IconChevronUp size={14} />
</ActionIcon>
<ActionIcon variant="subtle" disabled={sIndex === sections.length - 1} onClick={() => mutate(moveItem(sections, sIndex, 1))}>
<IconChevronDown size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="blue" onClick={() => setSectionDrawer({ section })}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteSection(section)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
<Stack gap="xs">
{section.fields.map((field, fIndex) => (
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<div style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fz="sm" fw={600} truncate>{localized(field.label) || field.key}</Text>
{field.required && <Text fz="xs" c="red" fw={700}>*</Text>}
</Group>
<Group gap={6}>
<Badge size="xs" variant="light">{field.type}</Badge>
<Text fz="xs" c="dimmed" truncate>key: {field.key}</Text>
{field.showWhen && <Badge size="xs" color="violet" variant="light">{t('certReq.condition.badge', 'conditional')}</Badge>}
</Group>
</div>
</Group>
<Group gap={4} wrap="nowrap">
<ActionIcon variant="subtle" size="sm" disabled={fIndex === 0} onClick={() => mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, -1) } : s)))}>
<IconChevronUp size={13} />
</ActionIcon>
<ActionIcon variant="subtle" size="sm" disabled={fIndex === section.fields.length - 1} onClick={() => mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, 1) } : s)))}>
<IconChevronDown size={13} />
</ActionIcon>
<ActionIcon variant="subtle" size="sm" color="blue" onClick={() => setFieldDrawer({ sectionKey: section.key, field })}>
<IconEdit size={13} />
</ActionIcon>
<ActionIcon variant="subtle" size="sm" color="red" onClick={() => setDeleteField({ sectionKey: section.key, field })}>
<IconTrash size={13} />
</ActionIcon>
</Group>
</Group>
</Card>
))}
<Button
size="xs"
variant="subtle"
leftSection={<IconPlus size={13} />}
onClick={() => setFieldDrawer({ sectionKey: section.key, field: null })}
>
{t('certReq.field.add', 'Add field')}
</Button>
</Stack>
</Card>
))}
</Stack>
)}
<SectionEditorDrawer
opened={sectionDrawer !== null}
onClose={() => setSectionDrawer(null)}
section={sectionDrawer?.section ?? null}
onSave={saveSection}
palette={palette}
conditionTargets={conditionTargets}
/>
<FieldEditorDrawer
opened={fieldDrawer !== null}
onClose={() => setFieldDrawer(null)}
field={fieldDrawer?.field ?? null}
onSave={saveField}
palette={palette}
conditionTargets={conditionTargets}
/>
<Modal opened={deleteSection !== null} onClose={() => setDeleteSection(null)} title={t('certReq.section.delete', 'Delete section')} size="sm">
<Stack gap="md">
<Text fz="sm">
{t('certReq.section.deleteConfirm', 'Remove "{{name}}" and all of its fields from this schema?', {
name: deleteSection ? localized(deleteSection.title) || deleteSection.key : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteSection(null)}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="red" onClick={confirmDeleteSection}>{t('certReq.delete', 'Delete')}</Button>
</ModalFooter>
</Stack>
</Modal>
<Modal opened={deleteField !== null} onClose={() => setDeleteField(null)} title={t('certReq.field.delete', 'Delete field')} size="sm">
<Stack gap="md">
<Text fz="sm">
{t('certReq.field.deleteConfirm', 'Remove "{{name}}" from this section?', {
name: deleteField ? localized(deleteField.field.label) || deleteField.field.key : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteField(null)}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="red" onClick={confirmDeleteField}>{t('certReq.delete', 'Delete')}</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { Button, Divider, Drawer, NumberInput, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import type { FormSchemaPalette, FormSectionConfig } from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
function emptySection(): FormSectionConfig {
return { key: '', title: { en: '', am: '' }, fields: [] };
}
/** Adds/edits one section's own metadata — its fields are managed on the list, not here. */
export function SectionEditorDrawer({
opened,
onClose,
section,
onSave,
palette,
conditionTargets,
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new section. */
section: FormSectionConfig | null;
onSave: (section: Omit<FormSectionConfig, 'fields'>) => void;
palette: FormSchemaPalette | undefined;
conditionTargets: ConditionTarget[];
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<FormSectionConfig>(emptySection());
const [keyError, setKeyError] = useState<string | null>(null);
const isNew = !section;
useEffect(() => {
if (opened) {
setDraft(section ? { ...section, title: { ...section.title } } : emptySection());
setKeyError(null);
}
}, [opened, section]);
function save() {
if (!draft.key.trim() || !KEY_PATTERN.test(draft.key.trim())) {
setKeyError(t('certReq.section.keyInvalid', 'Key must start with a letter and contain only letters, numbers, underscores'));
return;
}
if (!draft.title.en?.trim()) return;
const { fields: _fields, ...meta } = draft;
onSave({ ...meta, key: draft.key.trim() });
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={<Text fw={700}>{isNew ? t('certReq.section.add', 'Add section') : t('certReq.section.edit', 'Edit section')}</Text>}
>
<Stack gap="md">
<TextInput
label={t('certReq.section.key', 'Section key')}
placeholder="certificate"
required
value={draft.key}
error={keyError}
disabled={!isNew}
description={
isNew
? t('certReq.section.keyHelp', 'Letters, numbers and underscores only')
: t('certReq.section.keyLocked', 'Key cannot change once created')
}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
/>
<BilingualInput
label={t('certReq.section.title', 'Title')}
required
value={{ en: draft.title.en ?? '', am: draft.title.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, title: v }))}
/>
<BilingualInput
label={t('certReq.section.description', 'Description')}
value={{ en: draft.description?.en ?? '', am: draft.description?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
/>
<TextInput
label={t('certReq.section.group', 'Wizard step group')}
description={t(
'certReq.section.groupHelp',
'Sections sharing the same group render together on one step',
)}
value={draft.group ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))}
/>
<NumberInput
label={t('certReq.section.groupOrder', 'Group order')}
value={draft.groupOrder ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, groupOrder: typeof v === 'number' ? v : undefined }))}
/>
<NumberInput
label={t('certReq.section.sortOrder', 'Sort order')}
value={draft.sortOrder ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : undefined }))}
/>
<Divider label={t('certReq.condition.title', 'Visibility condition')} labelPosition="left" />
<ConditionBuilder
value={(draft.showWhen ?? null) as ConditionValue | null}
onChange={(v) => setDraft((d) => ({ ...d, showWhen: v ?? undefined }))}
targets={conditionTargets}
palette={palette}
/>
<ModalFooter>
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="teal" onClick={save}>
{isNew ? t('certReq.section.add', 'Add section') : t('certReq.saveChanges', 'Save changes')}
</Button>
</ModalFooter>
</Stack>
</Drawer>
);
}

View File

@@ -0,0 +1,28 @@
import type { FormFieldConfig, FormSectionConfig } from '@ema-platform/api';
/** One field reachable by a condition's dot path, with enough of its config
* to drive the value picker (SELECT offers its options; everything else is
* free text/number/boolean). */
export interface ConditionTarget {
/** `${sectionKey}.${fieldKey}` — what `FieldCondition.field` expects. */
path: string;
field: FormFieldConfig;
}
/**
* Every field in the schema a condition could point at.
*
* Drives the condition builder's autocomplete and its options-aware value
* picker — typing `certificate.` suggests `certificate.rank` because that
* section/field exists in this licence type's own schema, not because the
* rank list is known anywhere in the frontend.
*/
export function collectConditionTargets(sections: FormSectionConfig[]): ConditionTarget[] {
const targets: ConditionTarget[] = [];
for (const section of sections) {
for (const field of section.fields ?? []) {
targets.push({ path: `${section.key}.${field.key}`, field });
}
}
return targets;
}

View File

@@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import { extractErrorMessage } from '@ema-platform/api';
/**
* Runs a mutation and reports the outcome once — the same "one path, every
* action" pattern as the certificate designer's `useDesignerActions`.
*/
export function useRequirementActions() {
const { t } = useTranslation();
return useCallback(
async (action: () => Promise<unknown>, success: string) => {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
return true;
} catch (err) {
notifications.show({
color: 'red',
title: t('certReq.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
return false;
}
},
[t],
);
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { Container, Select, Stack, Tabs } from '@mantine/core';
import { IconAlertCircle, IconFileText, IconFiles, IconSettings } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui';
import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api';
import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab';
import { FormSchemaTab } from '../components/FormSchemaTab';
/**
* Where an administrator configures what an applicant must fill in and
* upload for a licence type — the form's sections/fields (with visibility
* conditions) and its document upload slots (with conditional requirements).
*
* Not scoped to CoC/CoP specifically: every active licence type is offered,
* because a form schema and its document requirements are properties of any
* licence type, not just certificates. CoC/CoP are simply the first types an
* administrator is expected to configure this way.
*/
export function CertificateRequirementsPage() {
const { t } = useTranslation();
const localized = useLocalized();
const { data: licenseTypes, isLoading, isError, error, refetch } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null);
const options = (licenseTypes?.items ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((lt) => ({ value: lt.id, label: `${localized(lt.name)} (${lt.key})` }));
useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
}, [licenseTypes, typeId]);
const selectedType = licenseTypes?.items?.find((lt) => lt.id === typeId);
return (
<Container size="xl" py="md">
<Stack gap="md">
<PageHeader
title={t('certReq.title', 'Certificate requirements')}
subtitle={t(
'certReq.subtitle',
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
)}
/>
{isError ? (
<ErrorState
title={t('certReq.loadFailed', 'Could not load licence types')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
icon={IconAlertCircle}
/>
) : isLoading ? (
<PageLoader label={t('certReq.loading', 'Loading licence types…')} height={300} />
) : (
<Stack gap="md">
<Select
label={t('certReq.licenseType', 'Licence type')}
placeholder={t('certReq.selectLicenseType', 'Select a licence type')}
data={options}
value={typeId}
onChange={setTypeId}
searchable
leftSection={<IconSettings size={15} />}
maw={480}
/>
{selectedType && (
<Tabs defaultValue="schema" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="schema" leftSection={<IconFileText size={15} />}>
{t('certReq.tabSchema', 'Form schema')}
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<IconFiles size={15} />}>
{t('certReq.tabDocuments', 'Document requirements')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="schema" pt="md">
<FormSchemaTab licenseType={selectedType} />
</Tabs.Panel>
<Tabs.Panel value="documents" pt="md">
<DocumentRequirementsTab licenseType={selectedType} />
</Tabs.Panel>
</Tabs>
)}
</Stack>
)}
</Stack>
</Container>
);
}
export default CertificateRequirementsPage;

View File

@@ -33,6 +33,7 @@ export const am: Translations = {
allApplications: "ሁሉም ማመልከቻዎች",
licenceRegister: "የፈቃድ መዝገብ",
certificateDesigner: "የምስክር ወረቀት ንድፍ",
certificateRequirements: "የምስክር ወረቀት መስፈርቶች",
byType: "በዓይነት",
typeFreightForwarder: "የጭነት አስተላላፊ",
typeShippingAgent: "የመርከብ ወኪል",
@@ -1171,6 +1172,124 @@ export const am: Translations = {
noPublishPermission: "ንድፎችን ማተም አይችሉም",
},
certReq: {
title: "የምስክር ወረቀት መስፈርቶች",
subtitle:
"ለፈቃድ ዓይነት አመልካቾች መሙላት ያለባቸውን የቅጽ መስኮች እና የሰነድ ሰቀላዎች ያዋቅሩ፣ መስፈርቱ መቼ ተግባራዊ እንደሚሆንም ጨምሮ።",
licenseType: "የፈቃድ ዓይነት",
selectLicenseType: "የፈቃድ ዓይነት ይምረጡ",
loading: "የፈቃድ ዓይነቶችን በመጫን ላይ…",
loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም",
tabSchema: "የቅጽ ቅንብር",
tabDocuments: "የሰነድ መስፈርቶች",
cancel: "ይቅር",
delete: "ሰርዝ",
saveChanges: "ለውጦችን አስቀምጥ",
actionFailed: "ተግባሩ አልተሳካም",
condition: {
title: "የሚታይበት ሁኔታ",
badge: "ሁኔታዊ",
enable: "ሁኔታ ሲሟላ ብቻ ተግባራዊ ይሁን",
field: "የመስክ መንገድ",
fieldHelp: "ወደ ቅጹ የነጥብ መንገድ፣ ለምሳሌ sectionKey.fieldKey",
operator: "አመልካች",
value: "ዋጋ",
values: "ከእነዚህ አንዱ",
removeValue: "ለማስወገድ ይጫኑ",
isSet: "ተሞልቷል",
isNotSet: "አልተሞላም",
unknownField: "ይህ መንገድ በአሁኑ ቅንብር ውስጥ ያለ መስክ አይደለም — ቢሆንም እንደተጻፈው ይቀመጣል።",
},
schema: {
subtitle:
"አመልካቹ ለዚህ የፈቃድ ዓይነት የሚያየው ክፍሎች እና መስኮች። አንድ ቡድን የሚጋሩ ክፍሎች በአንድ የዊዛርድ ደረጃ ላይ አብረው ይታያሉ።",
checkErrors: "ስህተቶችን ፈትሽ",
noIssues: "ምንም ችግር አልተገኘም",
issuesFound: "ችግሮች ተገኝተዋል",
save: "ቅንብር አስቀምጥ",
saved: "የቅጽ ቅንብር ተቀምጧል",
empty: "እስካሁን ክፍል የለም",
emptyBody: "ለዚህ የፈቃድ ዓይነት ቅጽ ለመገንባት ክፍል ይጨምሩ።",
},
section: {
add: "ክፍል ጨምር",
edit: "ክፍል አርትዕ",
delete: "ክፍል ሰርዝ",
deleteConfirm: '"{{name}}"ን እና ሁሉንም መስኮቹን ከዚህ ቅንብር ማስወገድ ይፈልጋሉ?',
key: "የክፍል ቁልፍ",
keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ",
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
title: "ርዕስ",
description: "መግለጫ",
group: "የዊዛርድ ደረጃ ቡድን",
groupHelp: "አንድ ቡድን የሚጋሩ ክፍሎች በአንድ ደረጃ ላይ አብረው ይታያሉ",
groupBadge: "ቡድን",
groupOrder: "የቡድን ቅደም ተከተል",
sortOrder: "የቅደም ተከተል ቁጥር",
},
field: {
add: "መስክ ጨምር",
edit: "መስክ አርትዕ",
delete: "መስክ ሰርዝ",
deleteConfirm: '"{{name}}"ን ከዚህ ክፍል ማስወገድ ይፈልጋሉ?',
key: "የመስክ ቁልፍ",
keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ — የቅጽ መረጃ ቁልፍ ይሆናል",
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
label: "መለያ",
type: "የመስክ ዓይነት",
required: "የግድ ያስፈልጋል",
placeholder: "ምሳሌ ጽሑፍ",
helpText: "የእገዛ ጽሑፍ",
min: "ዝቅተኛ",
max: "ከፍተኛ",
maxLength: "ከፍተኛ ርዝመት",
options: "አማራጮች",
addOption: "አማራጭ ጨምር",
optionValue: "ዋጋ",
optionLabel: "መለያ",
},
doc: {
subtitle: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለበት፣ በአዲስ ማመልከቻ እና በዕድሳት የተከፈለ።",
add: "የሰነድ መስፈርት ጨምር",
edit: "የሰነድ መስፈርት አርትዕ",
delete: "የሰነድ መስፈርት ሰርዝ",
deleteWarning: "በዚህ ቦታ ላይ የሚተማመኑ አመልካቾች ከዚህ በኋላ አያዩትም። ይህ መመለስ አይቻልም።",
deleteConfirm: '"{{name}}"ን ማስወገድ ይፈልጋሉ?',
created: "የሰነድ መስፈርት ተጨምሯል",
updated: "የሰነድ መስፈርት ተዘምኗል",
deleted: "የሰነድ መስፈርት ተወግዷል",
loading: "የሰነድ መስፈርቶችን በመጫን ላይ…",
loadFailed: "የሰነድ መስፈርቶችን መጫን አልተቻለም",
empty: "ምንም የሰነድ መስፈርት አልተዋቀረም",
emptyBody: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለባቸውን ሰነዶች ይጨምሩ።",
emptyKind: "ለዚህ የማመልከቻ ዓይነት እስካሁን የሰነድ መስፈርት የለም።",
key: "ቁልፍ",
keyHelp: "ይህን የሰነድ ቦታ የሚለይ ቋሚ መጠሪያ",
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
keyRequired: "ቁልፍ ያስፈልጋል",
name: "ስም",
description: "መግለጫ",
applicationKind: "የማመልከቻ ዓይነት",
kindNew: "አዲስ ማመልከቻ",
kindRenewal: "ዕድሳት",
kindLocked: "ከተፈጠረ በኋላ የማመልከቻ ዓይነት መቀየር አይቻልም",
mode: "ዘዴ",
modeAlways: "ሁልጊዜ ያስፈልጋል",
modeConditional: "ሁኔታ ሲሟላ ያስፈልጋል",
modeOptional: "አማራጭ ስቀላ",
conditionRequired: "ሁኔታዊ መስፈርት ሁኔታ ያስፈልገዋል",
allowedTypes: "የተፈቀዱ የፋይል ዓይነቶች",
maxSize: "ከፍተኛ የፋይል መጠን (MB)",
requiresValidity: "የቀን ገደብ ያስፈልጋል",
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
multiple: "ብዙ",
sortOrder: "የቅደም ተከተል ቁጥር",
when: "መቼ",
},
},
seafarerRegistry: {
title: "የመርከበኞች መዝገብ",
profileCount_one: "{{count}} መገለጫ",

View File

@@ -32,6 +32,7 @@ export const en = {
allApplications: 'All Applications',
licenceRegister: 'Licence Register',
certificateDesigner: 'Certificate Designer',
certificateRequirements: 'Certificate Requirements',
byType: 'By Type',
typeFreightForwarder: 'Freight Forwarder',
typeShippingAgent: 'Shipping Agent',
@@ -1173,6 +1174,124 @@ export const en = {
noPublishPermission: 'You cannot publish designs',
},
certReq: {
title: 'Certificate requirements',
subtitle:
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
licenseType: 'Licence type',
selectLicenseType: 'Select a licence type',
loading: 'Loading licence types…',
loadFailed: 'Could not load licence types',
tabSchema: 'Form schema',
tabDocuments: 'Document requirements',
cancel: 'Cancel',
delete: 'Delete',
saveChanges: 'Save changes',
actionFailed: 'Action failed',
condition: {
title: 'Visibility condition',
badge: 'conditional',
enable: 'Only apply when a condition holds',
field: 'Field path',
fieldHelp: 'Dot path into the form, e.g. sectionKey.fieldKey',
operator: 'Operator',
value: 'Value',
values: 'Any of',
removeValue: 'Click to remove',
isSet: 'is set',
isNotSet: 'is not set',
unknownField: 'This path is not a field in the current schema yet — it will still be saved as typed.',
},
schema: {
subtitle:
'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.',
checkErrors: 'Check for errors',
noIssues: 'No issues found',
issuesFound: 'Issues found',
save: 'Save schema',
saved: 'Form schema saved',
empty: 'No sections yet',
emptyBody: "Add a section to start building this licence type's form.",
},
section: {
add: 'Add section',
edit: 'Edit section',
delete: 'Delete section',
deleteConfirm: 'Remove "{{name}}" and all of its fields from this schema?',
key: 'Section key',
keyHelp: 'Letters, numbers and underscores only',
keyLocked: 'Key cannot change once created',
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
title: 'Title',
description: 'Description',
group: 'Wizard step group',
groupHelp: 'Sections sharing the same group render together on one step',
groupBadge: 'group',
groupOrder: 'Group order',
sortOrder: 'Sort order',
},
field: {
add: 'Add field',
edit: 'Edit field',
delete: 'Delete field',
deleteConfirm: 'Remove "{{name}}" from this section?',
key: 'Field key',
keyHelp: 'Letters, numbers and underscores only — becomes the form data key',
keyLocked: 'Key cannot change once created',
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
label: 'Label',
type: 'Field type',
required: 'Required',
placeholder: 'Placeholder',
helpText: 'Help text',
min: 'Minimum',
max: 'Maximum',
maxLength: 'Max length',
options: 'Options',
addOption: 'Add option',
optionValue: 'Value',
optionLabel: 'Label',
},
doc: {
subtitle: 'What an applicant must upload for this licence type, split by new application and renewal.',
add: 'Add document requirement',
edit: 'Edit document requirement',
delete: 'Delete document requirement',
deleteWarning: 'Applicants already relying on this slot will no longer see it. This cannot be undone.',
deleteConfirm: 'Remove "{{name}}"?',
created: 'Document requirement added',
updated: 'Document requirement updated',
deleted: 'Document requirement removed',
loading: 'Loading document requirements…',
loadFailed: 'Could not load document requirements',
empty: 'No document requirements configured',
emptyBody: 'Add the documents an applicant must upload for this licence type.',
emptyKind: 'No document requirements for this application kind yet.',
key: 'Key',
keyHelp: 'Stable slug identifying this document slot',
keyLocked: 'Key cannot change once created',
keyRequired: 'Key is required',
name: 'Name',
description: 'Description',
applicationKind: 'Application kind',
kindNew: 'New application',
kindRenewal: 'Renewal',
kindLocked: 'Application kind cannot change once created',
mode: 'Mode',
modeAlways: 'Always required',
modeConditional: 'Required when condition holds',
modeOptional: 'Optional upload',
conditionRequired: 'A conditional requirement needs a condition',
allowedTypes: 'Allowed file types',
maxSize: 'Max file size (MB)',
requiresValidity: 'Requires validity dates',
allowMultiple: 'Allow multiple uploads',
multiple: 'multiple',
sortOrder: 'Sort order',
when: 'when',
},
},
seafarerRegistry: {
title: 'Seafarer registry',
profileCount_one: '{{count}} profile',

View File

@@ -4,6 +4,7 @@ import {
IconBook2,
IconChartBar,
IconClipboardList,
IconClipboardText,
IconCreditCard,
IconFileDescription,
IconFilePlus,
@@ -144,6 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconRosetteDiscountCheck,
permissions: [P.VIEW_TEMPLATES],
},
{
to: '/certificate-requirements',
label: 'nav.certificateRequirements',
icon: IconClipboardText,
permissions: [P.VIEW_LICENSE_TYPES],
},
{
to: '/payment-config',
label: 'nav.paymentConfig',

View File

@@ -46,6 +46,7 @@ import { LicenseRegisterPage } from '../features/license-register/pages/LicenseR
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
/** Any-of gate shared by every licence-type queue and its review workspace. */
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
@@ -114,6 +115,8 @@ const router = createBrowserRouter([
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
// Config-driven review workspace, shared by every licence type.
{ path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
// Form schema + document requirement authoring, shared by every licence type.
{ path: 'certificate-requirements', element: guard([P.VIEW_LICENSE_TYPES], <CertificateRequirementsPage />) },
{ path: 'licence-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
// Deep link into the grid with the type facet pinned, so "Freight