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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -29,6 +29,7 @@ export type ActionId =
|
|||||||
| 'request-adjustment'
|
| 'request-adjustment'
|
||||||
| 'reject'
|
| 'reject'
|
||||||
| 'schedule-exam'
|
| 'schedule-exam'
|
||||||
|
| 'record-exam-outcome'
|
||||||
| 'confirm-payment'
|
| 'confirm-payment'
|
||||||
| 'schedule-issuance'
|
| 'schedule-issuance'
|
||||||
| 'issue-certificate'
|
| 'issue-certificate'
|
||||||
@@ -68,7 +69,9 @@ export const ACTIONS: ActionDefinition[] = [
|
|||||||
id: 'claim',
|
id: 'claim',
|
||||||
tier: 'workflow',
|
tier: 'workflow',
|
||||||
labelKey: 'review.actions.claim',
|
labelKey: 'review.actions.claim',
|
||||||
from: ['SUBMITTED'],
|
// 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'],
|
permissions: ['can:claim:license-application'],
|
||||||
emphasis: 'light',
|
emphasis: 'light',
|
||||||
},
|
},
|
||||||
@@ -198,7 +201,21 @@ export const ACTIONS: ActionDefinition[] = [
|
|||||||
// Only after the examination fee clears — scheduling an unpaid candidate
|
// Only after the examination fee clears — scheduling an unpaid candidate
|
||||||
// is what the EXAM_PAID gate exists to prevent.
|
// is what the EXAM_PAID gate exists to prevent.
|
||||||
from: ['EXAM_PAID'],
|
from: ['EXAM_PAID'],
|
||||||
permissions: ['can:schedule:exam-candidate'],
|
// Matches the controller's guard on `:id/exam-scheduled`
|
||||||
|
// (`LICENSE_PERMISSIONS.MANAGE_EXAMS`) — the previous string didn't
|
||||||
|
// correspond to any real permission constant, so this button could never
|
||||||
|
// actually be granted to anyone.
|
||||||
|
permissions: ['can:manage:exams'],
|
||||||
|
emphasis: 'filled',
|
||||||
|
color: 'cyan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'record-exam-outcome',
|
||||||
|
tier: 'primary',
|
||||||
|
labelKey: 'review.actions.recordExamOutcome',
|
||||||
|
// Only once the candidate has actually sat the exam.
|
||||||
|
from: ['EXAM_SCHEDULED'],
|
||||||
|
permissions: ['can:publish:exam-result'],
|
||||||
emphasis: 'filled',
|
emphasis: 'filled',
|
||||||
color: 'cyan',
|
color: 'cyan',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export function licenseQueueActionsColumn(
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
handlers.claimable !== false &&
|
handlers.claimable !== false &&
|
||||||
row.original.assignedOfficerId === null &&
|
row.original.assignedOfficerId === null &&
|
||||||
row.original.status === "SUBMITTED" ? (
|
// Mirrors the CLAIM transition's `from` list: an examined cert
|
||||||
|
// (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears,
|
||||||
|
// not SUBMITTED.
|
||||||
|
(row.original.status === "SUBMITTED" ||
|
||||||
|
row.original.status === "ELIGIBILITY_PAID") ? (
|
||||||
<RequirePermission
|
<RequirePermission
|
||||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||||
hideOnly
|
hideOnly
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
|
|||||||
"INSPECTION_COMPLETED",
|
"INSPECTION_COMPLETED",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Statuses only an examined cert (CoC, or a CoP with requiresExamination) reaches. */
|
||||||
|
const EXAM_ONLY_STATUSES: LicenseStatus[] = [
|
||||||
|
"ELIGIBILITY_PAYMENT_PENDING",
|
||||||
|
"ELIGIBILITY_PAID",
|
||||||
|
"EXAM_PAYMENT_PENDING",
|
||||||
|
"EXAM_PAID",
|
||||||
|
"EXAM_SCHEDULED",
|
||||||
|
"EXAM_PASSED",
|
||||||
|
"EXAM_FAILED",
|
||||||
|
];
|
||||||
|
|
||||||
const ALL_STATUSES: LicenseStatus[] = [
|
const ALL_STATUSES: LicenseStatus[] = [
|
||||||
"SUBMITTED",
|
"SUBMITTED",
|
||||||
"UNDER_REVIEW",
|
"UNDER_REVIEW",
|
||||||
@@ -96,9 +107,11 @@ const ALL_STATUSES: LicenseStatus[] = [
|
|||||||
"INSPECTION_COMPLETED",
|
"INSPECTION_COMPLETED",
|
||||||
"ON_HOLD",
|
"ON_HOLD",
|
||||||
"APPROVED",
|
"APPROVED",
|
||||||
|
...EXAM_ONLY_STATUSES,
|
||||||
"PAYMENT_PENDING",
|
"PAYMENT_PENDING",
|
||||||
"PAID",
|
"PAID",
|
||||||
"PAYMENT_CONFIRMED",
|
"PAYMENT_CONFIRMED",
|
||||||
|
"SCHEDULED",
|
||||||
"CERTIFICATE_ISSUED",
|
"CERTIFICATE_ISSUED",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"REJECTED",
|
"REJECTED",
|
||||||
@@ -117,6 +130,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
|||||||
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
||||||
return type.inspectionRequired;
|
return type.inspectionRequired;
|
||||||
}
|
}
|
||||||
|
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
||||||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
useIssueCertificateMutation,
|
useIssueCertificateMutation,
|
||||||
useScheduleExamMutation,
|
useScheduleExamMutation,
|
||||||
|
useRecordExamOutcomeMutation,
|
||||||
useEscalateApplicationMutation,
|
useEscalateApplicationMutation,
|
||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useGetApplicationForReviewQuery,
|
useGetApplicationForReviewQuery,
|
||||||
@@ -209,6 +210,7 @@ export function LicenseReviewPage() {
|
|||||||
const [issueCertificate] = useIssueCertificateMutation();
|
const [issueCertificate] = useIssueCertificateMutation();
|
||||||
const [scheduleExam, { isLoading: schedulingExam }] =
|
const [scheduleExam, { isLoading: schedulingExam }] =
|
||||||
useScheduleExamMutation();
|
useScheduleExamMutation();
|
||||||
|
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
||||||
const [holdApplication] = useHoldApplicationMutation();
|
const [holdApplication] = useHoldApplicationMutation();
|
||||||
const [resumeApplication] = useResumeApplicationMutation();
|
const [resumeApplication] = useResumeApplicationMutation();
|
||||||
const [escalateApplication] = useEscalateApplicationMutation();
|
const [escalateApplication] = useEscalateApplicationMutation();
|
||||||
@@ -234,6 +236,8 @@ export function LicenseReviewPage() {
|
|||||||
const [issuanceDate, setIssuanceDate] = useState("");
|
const [issuanceDate, setIssuanceDate] = useState("");
|
||||||
const [resultOpen, setResultOpen] = useState(false);
|
const [resultOpen, setResultOpen] = useState(false);
|
||||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||||
|
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||||
|
const [examScore, setExamScore] = useState<number | undefined>();
|
||||||
const [findings, setFindings] = useState("");
|
const [findings, setFindings] = useState("");
|
||||||
const [checklist, setChecklist] = useState<
|
const [checklist, setChecklist] = useState<
|
||||||
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
||||||
@@ -490,6 +494,11 @@ export function LicenseReviewPage() {
|
|||||||
case "schedule-exam":
|
case "schedule-exam":
|
||||||
setScheduleExamOpen(true);
|
setScheduleExamOpen(true);
|
||||||
return;
|
return;
|
||||||
|
// Pass/fail plus an optional score, same reasoning as schedule-exam:
|
||||||
|
// needs its own inputs before anything is sent.
|
||||||
|
case "record-exam-outcome":
|
||||||
|
setExamOutcomeOpen(true);
|
||||||
|
return;
|
||||||
case "copy-link":
|
case "copy-link":
|
||||||
navigator.clipboard.writeText(window.location.href);
|
navigator.clipboard.writeText(window.location.href);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -1173,6 +1182,73 @@ export function LicenseReviewPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={examOutcomeOpen}
|
||||||
|
onClose={() => setExamOutcomeOpen(false)}
|
||||||
|
title={t("review.actions.recordExamOutcome", "Record exam outcome")}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"review.examOutcome.intro",
|
||||||
|
"Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<NumberInput
|
||||||
|
label={t("review.examOutcome.score", "Score (optional)")}
|
||||||
|
value={examScore}
|
||||||
|
onChange={(v) => setExamScore(typeof v === "number" ? v : undefined)}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<ModalFooter grow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("review.passed", "Passed")}
|
||||||
|
onClick={() =>
|
||||||
|
run(
|
||||||
|
async () => {
|
||||||
|
await recordExamOutcome({
|
||||||
|
id,
|
||||||
|
passed: true,
|
||||||
|
score: examScore,
|
||||||
|
}).unwrap();
|
||||||
|
setExamOutcomeOpen(false);
|
||||||
|
setExamScore(undefined);
|
||||||
|
},
|
||||||
|
t("review.done.examPassed", "Exam result recorded — passed"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconCheck size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("review.failed", "Failed")}
|
||||||
|
onClick={() =>
|
||||||
|
run(
|
||||||
|
async () => {
|
||||||
|
await recordExamOutcome({
|
||||||
|
id,
|
||||||
|
passed: false,
|
||||||
|
score: examScore,
|
||||||
|
}).unwrap();
|
||||||
|
setExamOutcomeOpen(false);
|
||||||
|
setExamScore(undefined);
|
||||||
|
},
|
||||||
|
t("review.done.examFailed", "Exam result recorded — not passed"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</ModalFooter>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={inspectionOpen}
|
opened={inspectionOpen}
|
||||||
onClose={() => setInspectionOpen(false)}
|
onClose={() => setInspectionOpen(false)}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const am: Translations = {
|
|||||||
allApplications: "ሁሉም ማመልከቻዎች",
|
allApplications: "ሁሉም ማመልከቻዎች",
|
||||||
licenceRegister: "የፈቃድ መዝገብ",
|
licenceRegister: "የፈቃድ መዝገብ",
|
||||||
certificateDesigner: "የምስክር ወረቀት ንድፍ",
|
certificateDesigner: "የምስክር ወረቀት ንድፍ",
|
||||||
|
certificateRequirements: "የምስክር ወረቀት መስፈርቶች",
|
||||||
byType: "በዓይነት",
|
byType: "በዓይነት",
|
||||||
typeFreightForwarder: "የጭነት አስተላላፊ",
|
typeFreightForwarder: "የጭነት አስተላላፊ",
|
||||||
typeShippingAgent: "የመርከብ ወኪል",
|
typeShippingAgent: "የመርከብ ወኪል",
|
||||||
@@ -1171,6 +1172,124 @@ export const am: Translations = {
|
|||||||
noPublishPermission: "ንድፎችን ማተም አይችሉም",
|
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: {
|
seafarerRegistry: {
|
||||||
title: "የመርከበኞች መዝገብ",
|
title: "የመርከበኞች መዝገብ",
|
||||||
profileCount_one: "{{count}} መገለጫ",
|
profileCount_one: "{{count}} መገለጫ",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const en = {
|
|||||||
allApplications: 'All Applications',
|
allApplications: 'All Applications',
|
||||||
licenceRegister: 'Licence Register',
|
licenceRegister: 'Licence Register',
|
||||||
certificateDesigner: 'Certificate Designer',
|
certificateDesigner: 'Certificate Designer',
|
||||||
|
certificateRequirements: 'Certificate Requirements',
|
||||||
byType: 'By Type',
|
byType: 'By Type',
|
||||||
typeFreightForwarder: 'Freight Forwarder',
|
typeFreightForwarder: 'Freight Forwarder',
|
||||||
typeShippingAgent: 'Shipping Agent',
|
typeShippingAgent: 'Shipping Agent',
|
||||||
@@ -1173,6 +1174,124 @@ export const en = {
|
|||||||
noPublishPermission: 'You cannot publish designs',
|
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: {
|
seafarerRegistry: {
|
||||||
title: 'Seafarer registry',
|
title: 'Seafarer registry',
|
||||||
profileCount_one: '{{count}} profile',
|
profileCount_one: '{{count}} profile',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IconBook2,
|
IconBook2,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
|
IconClipboardText,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconFilePlus,
|
IconFilePlus,
|
||||||
@@ -144,6 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
icon: IconRosetteDiscountCheck,
|
icon: IconRosetteDiscountCheck,
|
||||||
permissions: [P.VIEW_TEMPLATES],
|
permissions: [P.VIEW_TEMPLATES],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/certificate-requirements',
|
||||||
|
label: 'nav.certificateRequirements',
|
||||||
|
icon: IconClipboardText,
|
||||||
|
permissions: [P.VIEW_LICENSE_TYPES],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/payment-config',
|
to: '/payment-config',
|
||||||
label: 'nav.paymentConfig',
|
label: 'nav.paymentConfig',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { LicenseRegisterPage } from '../features/license-register/pages/LicenseR
|
|||||||
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
||||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
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. */
|
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
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 /> },
|
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
// Config-driven review workspace, shared by every licence type.
|
// Config-driven review workspace, shared by every licence type.
|
||||||
{ path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
|
{ 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-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
|
||||||
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
|
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
|
||||||
// Deep link into the grid with the type facet pinned, so "Freight
|
// Deep link into the grid with the type facet pinned, so "Freight
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ const STATUS_COLOR: Record<string, string> = {
|
|||||||
RESUBMIT_REQUIRED: 'orange',
|
RESUBMIT_REQUIRED: 'orange',
|
||||||
INSPECTION_PENDING: 'grape',
|
INSPECTION_PENDING: 'grape',
|
||||||
INSPECTION_COMPLETED: 'grape',
|
INSPECTION_COMPLETED: 'grape',
|
||||||
ELIGIBILITY_APPROVED: 'teal',
|
ELIGIBILITY_PAYMENT_PENDING: 'orange',
|
||||||
|
ELIGIBILITY_PAID: 'blue',
|
||||||
EXAM_PAYMENT_PENDING: 'orange',
|
EXAM_PAYMENT_PENDING: 'orange',
|
||||||
EXAM_PAID: 'blue',
|
EXAM_PAID: 'blue',
|
||||||
EXAM_SCHEDULED: 'indigo',
|
EXAM_SCHEDULED: 'indigo',
|
||||||
@@ -220,14 +221,25 @@ export function CertificatesPage() {
|
|||||||
{/* Tooltip needs a hoverable child even while the button itself is
|
{/* Tooltip needs a hoverable child even while the button itself is
|
||||||
disabled, so the reason still shows on hover. */}
|
disabled, so the reason still shows on hover. */}
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Group gap="xs">
|
||||||
leftSection={<IconShieldCheck size={15} />}
|
<Button
|
||||||
rightSection={<IconArrowRight size={15} />}
|
leftSection={<IconShieldCheck size={15} />}
|
||||||
onClick={() => navigate('/certificates/apply')}
|
rightSection={<IconArrowRight size={15} />}
|
||||||
disabled={!canApply}
|
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
|
||||||
>
|
disabled={!canApply}
|
||||||
Apply for CoC / CoP
|
>
|
||||||
</Button>
|
Apply for CoC
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconShieldCheck size={15} />}
|
||||||
|
rightSection={<IconArrowRight size={15} />}
|
||||||
|
onClick={() => navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')}
|
||||||
|
disabled={!canApply}
|
||||||
|
>
|
||||||
|
Apply for CoP
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -269,7 +281,7 @@ export function CertificatesPage() {
|
|||||||
<Text fw={700} mb="md">My Applications</Text>
|
<Text fw={700} mb="md">My Applications</Text>
|
||||||
{applications.length === 0 ? (
|
{applications.length === 0 ? (
|
||||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||||
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
|
No active CoC/CoP applications. Click "Apply for CoC" or "Apply for CoP" to start.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -144,6 +144,11 @@ export function DocumentSlots({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
{requirement.description && (
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{localized(requirement.description)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{existing?.files?.[0] && (
|
{existing?.files?.[0] && (
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{existing.files[0].originalName} ·{' '}
|
{existing.files[0].originalName} ·{' '}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ interface Props {
|
|||||||
t: TFunction;
|
t: TFunction;
|
||||||
requesting: boolean;
|
requesting: boolean;
|
||||||
paying: boolean;
|
paying: boolean;
|
||||||
onRequestExamFee: (app: LicenseApplication) => void;
|
onRetakeExam: (app: LicenseApplication) => void;
|
||||||
onPay: (app: LicenseApplication) => void;
|
onPay: (app: LicenseApplication) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the candidate can do while an examined certificate is in its exam leg.
|
* What the candidate can do while an examined certificate is in its
|
||||||
|
* eligibility or exam leg.
|
||||||
*
|
*
|
||||||
* Kept apart from the general actions column because these statuses only ever
|
* Kept apart from the general actions column because these statuses only ever
|
||||||
* occur on types that examine — folding them into that column would put five
|
* occur on types that examine — folding them into that column would put five
|
||||||
@@ -26,24 +27,50 @@ export function ExamStageActions({
|
|||||||
t,
|
t,
|
||||||
requesting,
|
requesting,
|
||||||
paying,
|
paying,
|
||||||
onRequestExamFee,
|
onRetakeExam,
|
||||||
onPay,
|
onPay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Eligible but not yet committed to sitting, or sat and not passed: both are
|
// The eligibility fee is invoiced the moment the application is submitted —
|
||||||
// the same decision — ask for the fee that buys a sitting.
|
// there is no separate "request" step, so this is a pay button, exactly
|
||||||
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
|
// like EXAM_PAYMENT_PENDING below.
|
||||||
const retake = app.status === 'EXAM_FAILED';
|
if (app.status === 'ELIGIBILITY_PAYMENT_PENDING') {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color={retake ? 'orange' : 'teal'}
|
color="yellow"
|
||||||
loading={requesting}
|
loading={paying}
|
||||||
onClick={() => onRequestExamFee(app)}
|
onClick={() => onPay(app)}
|
||||||
>
|
>
|
||||||
{retake
|
{t('applications.actions.payEligibilityFee', {
|
||||||
? t('applications.actions.bookRetake', 'Book a resit')
|
defaultValue: 'Pay eligibility fee ({{amount}} {{currency}})',
|
||||||
: t('applications.actions.bookExam', 'Book exam')}
|
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||||
|
currency: app.feeCurrency,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paid — queued for backoffice review. Nothing for the candidate to do.
|
||||||
|
if (app.status === 'ELIGIBILITY_PAID') {
|
||||||
|
return (
|
||||||
|
<Button size="xs" variant="subtle" disabled>
|
||||||
|
{t('applications.actions.eligibilityUnderReview', 'Under review')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed a sitting: the only decision left is whether to pay for another.
|
||||||
|
if (app.status === 'EXAM_FAILED') {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="filled"
|
||||||
|
color="orange"
|
||||||
|
loading={requesting}
|
||||||
|
onClick={() => onRetakeExam(app)}
|
||||||
|
>
|
||||||
|
{t('applications.actions.bookRetake', 'Book a resit')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -737,9 +737,14 @@ export function LicenseApplicationPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={section.key}>
|
<div key={section.key}>
|
||||||
{index > 0 && <Divider mb="lg" />}
|
{index > 0 && <Divider mb="lg" />}
|
||||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb={section.description ? 4 : "sm"}>
|
||||||
{localized(section.title)}
|
{localized(section.title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{section.description && (
|
||||||
|
<Text fz="xs" c="dimmed" mb="sm">
|
||||||
|
{localized(section.description)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{locked && (
|
{locked && (
|
||||||
<Alert
|
<Alert
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { ExamStageActions } from '../../components/ExamStageActions';
|
|||||||
|
|
||||||
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
||||||
const EXAM_STAGE_STATUSES = [
|
const EXAM_STAGE_STATUSES = [
|
||||||
'ELIGIBILITY_APPROVED',
|
'ELIGIBILITY_PAYMENT_PENDING',
|
||||||
|
'ELIGIBILITY_PAID',
|
||||||
'EXAM_PAYMENT_PENDING',
|
'EXAM_PAYMENT_PENDING',
|
||||||
'EXAM_PAID',
|
'EXAM_PAID',
|
||||||
'EXAM_SCHEDULED',
|
'EXAM_SCHEDULED',
|
||||||
@@ -23,12 +24,12 @@ export function applicationActionsColumn(
|
|||||||
bypassEnabled: boolean;
|
bypassEnabled: boolean;
|
||||||
bypassing: boolean;
|
bypassing: boolean;
|
||||||
isPaying: boolean;
|
isPaying: boolean;
|
||||||
/** True while the exam fee is being raised for a booking or a resit. */
|
/** True while a resit is being requested. */
|
||||||
requestingExamFee: boolean;
|
requestingExamFee: boolean;
|
||||||
onBypass: (app: LicenseApplication) => void;
|
onBypass: (app: LicenseApplication) => void;
|
||||||
onCertificate: (app: LicenseApplication) => void;
|
onCertificate: (app: LicenseApplication) => void;
|
||||||
onPay: (app: LicenseApplication) => void;
|
onPay: (app: LicenseApplication) => void;
|
||||||
onRequestExamFee: (app: LicenseApplication) => void;
|
onRetakeExam: (app: LicenseApplication) => void;
|
||||||
onOpen: (app: LicenseApplication) => void;
|
onOpen: (app: LicenseApplication) => void;
|
||||||
},
|
},
|
||||||
): AdvancedColumn<LicenseApplication> {
|
): AdvancedColumn<LicenseApplication> {
|
||||||
@@ -46,14 +47,17 @@ export function applicationActionsColumn(
|
|||||||
t={t}
|
t={t}
|
||||||
requesting={deps.requestingExamFee}
|
requesting={deps.requestingExamFee}
|
||||||
paying={deps.isPaying}
|
paying={deps.isPaying}
|
||||||
onRequestExamFee={deps.onRequestExamFee}
|
onRetakeExam={deps.onRetakeExam}
|
||||||
onPay={deps.onPay}
|
onPay={deps.onPay}
|
||||||
/>
|
/>
|
||||||
{/* Both fee stops are bypassable — an examined certificate is
|
{/* Every fee stop is bypassable — an examined certificate charges
|
||||||
|
three separate fees (eligibility, exam, certificate) and is
|
||||||
otherwise untestable without a live gateway. */}
|
otherwise untestable without a live gateway. */}
|
||||||
{deps.bypassEnabled &&
|
{deps.bypassEnabled &&
|
||||||
(app.status === 'PAYMENT_PENDING' ||
|
(app.status === 'PAYMENT_PENDING' ||
|
||||||
app.status === 'EXAM_PAYMENT_PENDING') && (
|
app.status === 'ELIGIBILITY_PAYMENT_PENDING' ||
|
||||||
|
app.status === 'EXAM_PAYMENT_PENDING' ||
|
||||||
|
app.status === 'EXAM_PASSED') && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
useRequestExamPaymentMutation,
|
useRetakeExamMutation,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
@@ -99,8 +99,7 @@ export function MyApplicationsPage() {
|
|||||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||||
const [requestExamPayment, { isLoading: requestingExamFee }] =
|
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||||
useRequestExamPaymentMutation();
|
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
const { renewLicense, isRenewing } = useRenewLicense();
|
const { renewLicense, isRenewing } = useRenewLicense();
|
||||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||||
@@ -135,15 +134,15 @@ export function MyApplicationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raises the examination fee, for a first sitting or a resit.
|
* Re-opens the examination fee for a failed candidate.
|
||||||
*
|
*
|
||||||
* Payment is a separate step: this only moves the application to
|
* Payment is a separate step: this only moves the application back to
|
||||||
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
|
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
|
||||||
* the provider the same way every other fee does.
|
* the provider the same way every other fee does.
|
||||||
*/
|
*/
|
||||||
async function requestExamFee(applicationId: string) {
|
async function retakeExamFee(applicationId: string) {
|
||||||
try {
|
try {
|
||||||
await requestExamPayment(applicationId).unwrap();
|
await retakeExam(applicationId).unwrap();
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'teal',
|
color: 'teal',
|
||||||
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
||||||
@@ -281,7 +280,7 @@ export function MyApplicationsPage() {
|
|||||||
onBypass: (app) => handleBypass(app.id),
|
onBypass: (app) => handleBypass(app.id),
|
||||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||||
onPay: (app) => pay(app.id),
|
onPay: (app) => pay(app.id),
|
||||||
onRequestExamFee: (app) => requestExamFee(app.id),
|
onRetakeExam: (app) => retakeExamFee(app.id),
|
||||||
onOpen: (app) =>
|
onOpen: (app) =>
|
||||||
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import { NotificationsPage } from "./features/notifications/pages/NotificationsP
|
|||||||
|
|
||||||
// Phase 2 — CoC / CoP
|
// Phase 2 — CoC / CoP
|
||||||
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||||
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||||
@@ -262,14 +261,9 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
// CoC/CoP applications go through the generic license wizard —
|
||||||
path: "/certificates/apply",
|
// /licensing/CERTIFICATE_OF_COMPETENCY/apply and
|
||||||
element: (
|
// /licensing/CERTIFICATE_OF_PROFICIENCY/apply, wired below.
|
||||||
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
|
||||||
<CoCApplicationPage />
|
|
||||||
</RequirePermission>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export const mockApplications: Record<string, any> = {
|
|||||||
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||||
applicantUserId: 'user-mock-001',
|
applicantUserId: 'user-mock-001',
|
||||||
kind: 'RENEWAL',
|
kind: 'RENEWAL',
|
||||||
status: 'ELIGIBILITY_APPROVED',
|
status: 'ELIGIBILITY_PAID',
|
||||||
assignedOfficerId: 'officer-mock-002',
|
assignedOfficerId: 'officer-mock-002',
|
||||||
claimedAt: '2026-08-01T10:00:00.000Z',
|
claimedAt: '2026-08-01T10:00:00.000Z',
|
||||||
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import type {
|
|||||||
ApplicationPayment,
|
ApplicationPayment,
|
||||||
ApplicationStaff,
|
ApplicationStaff,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
DocumentRequirement,
|
||||||
|
FormSchemaPalette,
|
||||||
|
FormSectionConfig,
|
||||||
InitiatePaymentResult,
|
InitiatePaymentResult,
|
||||||
IssuedLicense,
|
IssuedLicense,
|
||||||
Inspection,
|
Inspection,
|
||||||
@@ -25,6 +28,7 @@ import type {
|
|||||||
QueueFilter,
|
QueueFilter,
|
||||||
RemarkTargetType,
|
RemarkTargetType,
|
||||||
SavedQueueView,
|
SavedQueueView,
|
||||||
|
SchemaIssue,
|
||||||
TemplateFieldPlacement,
|
TemplateFieldPlacement,
|
||||||
TemplateLogoPlacement,
|
TemplateLogoPlacement,
|
||||||
TemplatePageOptions,
|
TemplatePageOptions,
|
||||||
@@ -66,6 +70,7 @@ const TAGS = [
|
|||||||
'License',
|
'License',
|
||||||
'SavedView',
|
'SavedView',
|
||||||
'LicenseTemplate',
|
'LicenseTemplate',
|
||||||
|
'DocumentRequirement',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||||
@@ -178,6 +183,76 @@ export const licensingApi = baseApi
|
|||||||
providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)],
|
providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ------------------------------------------------ form-schema builder
|
||||||
|
/** Replaces a licence type's form schema. Server re-validates on save. */
|
||||||
|
updateFormSchema: builder.mutation<
|
||||||
|
LicenseType,
|
||||||
|
{ id: string; formSchema: { sections: FormSectionConfig[] } }
|
||||||
|
>({
|
||||||
|
query: ({ id, formSchema }) => ({
|
||||||
|
url: `/license-types/${id}/form-schema`,
|
||||||
|
method: 'PUT',
|
||||||
|
body: { formSchema },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Dry-run lint, for inline feedback while the schema is being edited. */
|
||||||
|
validateFormSchema: builder.mutation<
|
||||||
|
{ valid: boolean; issues: SchemaIssue[] },
|
||||||
|
{ formSchema: { sections: FormSectionConfig[] }; licenseTypeId?: string }
|
||||||
|
>({
|
||||||
|
query: (body) => ({
|
||||||
|
url: '/license-types/form-schema/validate',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Field types, condition operators and prefill sources the builder may offer. */
|
||||||
|
getFormSchemaPalette: builder.query<FormSchemaPalette, void>({
|
||||||
|
query: () => ({ url: '/license-types/form-schema/palette' }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ------------------------------------------------- document requirements
|
||||||
|
/**
|
||||||
|
* Every document requirement, for the admin editor to filter by licence
|
||||||
|
* type client-side. The collection-query `q` filter syntax (`w=column:op:
|
||||||
|
* value`) has no typed builder on this side, and the table is small
|
||||||
|
* configuration data with no pagination need — see `licenseTypeId` usage
|
||||||
|
* at the call site.
|
||||||
|
*/
|
||||||
|
getDocumentRequirements: builder.query<Paginated<DocumentRequirement>, void>({
|
||||||
|
query: () => ({ url: '/document-requirements' }),
|
||||||
|
providesTags: () => [listTag('DocumentRequirement')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDocumentRequirement: builder.mutation<
|
||||||
|
DocumentRequirement,
|
||||||
|
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
|
||||||
|
>({
|
||||||
|
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateDocumentRequirement: builder.mutation<
|
||||||
|
DocumentRequirement,
|
||||||
|
{ id: string } & Partial<DocumentRequirement>
|
||||||
|
>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
|
url: `/document-requirements/${id}`,
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteDocumentRequirement: builder.mutation<unknown, string>({
|
||||||
|
query: (id) => ({ url: `/document-requirements/${id}`, method: 'DELETE' }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||||
|
}),
|
||||||
|
|
||||||
// -------------------------------------------------------- application
|
// -------------------------------------------------------- application
|
||||||
createApplication: builder.mutation<
|
createApplication: builder.mutation<
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
@@ -494,9 +569,26 @@ export const licensingApi = baseApi
|
|||||||
scheduleExam: builder.mutation<
|
scheduleExam: builder.mutation<
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
|
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, examDate: _examDate, ...body }) => ({
|
||||||
|
// Matches the controller's `:id/exam-scheduled` route — `examDate`
|
||||||
|
// is UI-only context for the confirmation toast, not part of
|
||||||
|
// `MarkExamScheduledDto`, so it never goes on the wire.
|
||||||
|
url: `/license-application-review/${id}/exam-scheduled`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Records a published examination result (pass or fail). */
|
||||||
|
recordExamOutcome: builder.mutation<
|
||||||
|
LicenseApplication,
|
||||||
|
{ id: string; passed: boolean; score?: number }
|
||||||
>({
|
>({
|
||||||
query: ({ id, ...body }) => ({
|
query: ({ id, ...body }) => ({
|
||||||
url: `/license-application-review/${id}/schedule-exam`,
|
url: `/license-application-review/${id}/exam-outcome`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
@@ -505,12 +597,13 @@ export const licensingApi = baseApi
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raises the examination fee — after eligibility approval, or again when
|
* A failed candidate asks for another sitting. Re-opens the examination
|
||||||
* a failed candidate elects to resit.
|
* fee (EXAM_FAILED -> EXAM_PAYMENT_PENDING); eligibility was already
|
||||||
|
* assessed and paid for on the first attempt.
|
||||||
*/
|
*/
|
||||||
requestExamPayment: builder.mutation<LicenseApplication, string>({
|
retakeExam: builder.mutation<LicenseApplication, string>({
|
||||||
query: (id) => ({
|
query: (id) => ({
|
||||||
url: `/license-applications/${id}/request-exam-payment`,
|
url: `/license-applications/${id}/retake`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, error, id) =>
|
invalidatesTags: (_r, error, id) =>
|
||||||
@@ -809,6 +902,13 @@ export const {
|
|||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetLicenseCategoriesQuery,
|
useGetLicenseCategoriesQuery,
|
||||||
useUpdateLicenseFeesMutation,
|
useUpdateLicenseFeesMutation,
|
||||||
|
useUpdateFormSchemaMutation,
|
||||||
|
useValidateFormSchemaMutation,
|
||||||
|
useGetFormSchemaPaletteQuery,
|
||||||
|
useGetDocumentRequirementsQuery,
|
||||||
|
useCreateDocumentRequirementMutation,
|
||||||
|
useUpdateDocumentRequirementMutation,
|
||||||
|
useDeleteDocumentRequirementMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
useGetLicenseTypeRequirementsQuery,
|
useGetLicenseTypeRequirementsQuery,
|
||||||
useCreateApplicationMutation,
|
useCreateApplicationMutation,
|
||||||
@@ -864,7 +964,8 @@ export const {
|
|||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useRejectApplicationMutation,
|
useRejectApplicationMutation,
|
||||||
useScheduleExamMutation,
|
useScheduleExamMutation,
|
||||||
useRequestExamPaymentMutation,
|
useRecordExamOutcomeMutation,
|
||||||
|
useRetakeExamMutation,
|
||||||
useConfirmPaymentMutation,
|
useConfirmPaymentMutation,
|
||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
useIssueCertificateMutation,
|
useIssueCertificateMutation,
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
|||||||
SCHEDULED: 'Pickup Scheduled',
|
SCHEDULED: 'Pickup Scheduled',
|
||||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||||
COMPLETED: 'Completed',
|
COMPLETED: 'Completed',
|
||||||
ELIGIBILITY_APPROVED: 'Eligible to Sit',
|
ELIGIBILITY_PAYMENT_PENDING: 'Eligibility Fee Due',
|
||||||
|
ELIGIBILITY_PAID: 'Eligibility Under Review',
|
||||||
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
|
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
|
||||||
EXAM_PAID: 'Awaiting Exam Date',
|
EXAM_PAID: 'Awaiting Exam Date',
|
||||||
EXAM_SCHEDULED: 'Exam Scheduled',
|
EXAM_SCHEDULED: 'Exam Scheduled',
|
||||||
@@ -101,7 +102,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
|||||||
SCHEDULED: 'cyan',
|
SCHEDULED: 'cyan',
|
||||||
CERTIFICATE_ISSUED: 'green',
|
CERTIFICATE_ISSUED: 'green',
|
||||||
COMPLETED: 'green',
|
COMPLETED: 'green',
|
||||||
ELIGIBILITY_APPROVED: 'teal',
|
ELIGIBILITY_PAYMENT_PENDING: 'yellow',
|
||||||
|
ELIGIBILITY_PAID: 'lime',
|
||||||
EXAM_PAYMENT_PENDING: 'yellow',
|
EXAM_PAYMENT_PENDING: 'yellow',
|
||||||
EXAM_PAID: 'lime',
|
EXAM_PAID: 'lime',
|
||||||
EXAM_SCHEDULED: 'cyan',
|
EXAM_SCHEDULED: 'cyan',
|
||||||
@@ -136,7 +138,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
|||||||
REJECTED: 100,
|
REJECTED: 100,
|
||||||
// The exam leg sits between approval and the certificate fee, so these
|
// The exam leg sits between approval and the certificate fee, so these
|
||||||
// interleave with PAYMENT_PENDING (80) rather than running past it.
|
// interleave with PAYMENT_PENDING (80) rather than running past it.
|
||||||
ELIGIBILITY_APPROVED: 60,
|
ELIGIBILITY_PAYMENT_PENDING: 52,
|
||||||
|
ELIGIBILITY_PAID: 56,
|
||||||
EXAM_PAYMENT_PENDING: 64,
|
EXAM_PAYMENT_PENDING: 64,
|
||||||
EXAM_PAID: 68,
|
EXAM_PAID: 68,
|
||||||
EXAM_SCHEDULED: 72,
|
EXAM_SCHEDULED: 72,
|
||||||
@@ -150,6 +153,9 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
|||||||
'DRAFT',
|
'DRAFT',
|
||||||
'RESUBMIT_REQUIRED',
|
'RESUBMIT_REQUIRED',
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
|
// Due the moment an examined application is submitted, before any officer
|
||||||
|
// looks at it.
|
||||||
|
'ELIGIBILITY_PAYMENT_PENDING',
|
||||||
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
||||||
// sit again after a failure.
|
// sit again after a failure.
|
||||||
'EXAM_PAYMENT_PENDING',
|
'EXAM_PAYMENT_PENDING',
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ export type LicenseStatus =
|
|||||||
| "SCHEDULED"
|
| "SCHEDULED"
|
||||||
| "CERTIFICATE_ISSUED"
|
| "CERTIFICATE_ISSUED"
|
||||||
| "COMPLETED"
|
| "COMPLETED"
|
||||||
// Examined certificates (CoC, some CoP): approval establishes eligibility,
|
// Examined certificates (CoC, some CoP): the eligibility assessment fee is
|
||||||
// the candidate pays to sit, and the certificate fee falls due on a pass.
|
// due before review starts, then the candidate pays to sit, and the
|
||||||
| "ELIGIBILITY_APPROVED"
|
// certificate fee falls due on a pass.
|
||||||
|
| "ELIGIBILITY_PAYMENT_PENDING"
|
||||||
|
| "ELIGIBILITY_PAID"
|
||||||
| "EXAM_PAYMENT_PENDING"
|
| "EXAM_PAYMENT_PENDING"
|
||||||
| "EXAM_PAID"
|
| "EXAM_PAID"
|
||||||
| "EXAM_SCHEDULED"
|
| "EXAM_SCHEDULED"
|
||||||
@@ -77,10 +79,12 @@ export interface FormFieldConfig {
|
|||||||
label: Bilingual;
|
label: Bilingual;
|
||||||
type: FormFieldType;
|
type: FormFieldType;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
placeholder?: Bilingual;
|
||||||
helpText?: Bilingual;
|
helpText?: Bilingual;
|
||||||
options?: { value: string; label: Bilingual }[];
|
options?: { value: string; label: Bilingual }[];
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
|
maxLength?: number;
|
||||||
showWhen?: FieldCondition;
|
showWhen?: FieldCondition;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -233,6 +237,7 @@ export interface StcwCapacityRow {
|
|||||||
|
|
||||||
export interface DocumentRequirement {
|
export interface DocumentRequirement {
|
||||||
id: string;
|
id: string;
|
||||||
|
licenseTypeId: string;
|
||||||
key: string;
|
key: string;
|
||||||
name: Bilingual;
|
name: Bilingual;
|
||||||
description?: Bilingual;
|
description?: Bilingual;
|
||||||
@@ -242,7 +247,32 @@ export interface DocumentRequirement {
|
|||||||
allowedMimeTypes: string[];
|
allowedMimeTypes: string[];
|
||||||
maxSizeMb: number;
|
maxSizeMb: number;
|
||||||
requiresValidityDates: boolean;
|
requiresValidityDates: boolean;
|
||||||
|
allowMultiple: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the form-schema builder may put on a form — the field types the engine
|
||||||
|
* understands, which constraints each one honours, the condition operators it
|
||||||
|
* supports, and the prefill sources a read-only field may draw from. Drives
|
||||||
|
* the builder's pickers so they never hardcode a list the server already owns.
|
||||||
|
*/
|
||||||
|
export interface FormSchemaPalette {
|
||||||
|
fieldTypes: {
|
||||||
|
type: FormFieldType;
|
||||||
|
supportsOptions: boolean;
|
||||||
|
supportsRange: boolean;
|
||||||
|
supportsMaxLength: boolean;
|
||||||
|
}[];
|
||||||
|
conditionOperators: ("equals" | "notEquals" | "in" | "isSet")[];
|
||||||
|
prefillSources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One problem the server's form-schema lint found. */
|
||||||
|
export interface SchemaIssue {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StaffEvidenceRequirement {
|
export interface StaffEvidenceRequirement {
|
||||||
|
|||||||
Reference in New Issue
Block a user