mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02: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'
|
||||
| 'reject'
|
||||
| 'schedule-exam'
|
||||
| 'record-exam-outcome'
|
||||
| 'confirm-payment'
|
||||
| 'schedule-issuance'
|
||||
| 'issue-certificate'
|
||||
@@ -68,7 +69,9 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
id: 'claim',
|
||||
tier: 'workflow',
|
||||
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'],
|
||||
emphasis: 'light',
|
||||
},
|
||||
@@ -198,7 +201,21 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
// Only after the examination fee clears — scheduling an unpaid candidate
|
||||
// is what the EXAM_PAID gate exists to prevent.
|
||||
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',
|
||||
color: 'cyan',
|
||||
},
|
||||
|
||||
@@ -22,7 +22,11 @@ export function licenseQueueActionsColumn(
|
||||
cell: ({ row }) =>
|
||||
handlers.claimable !== false &&
|
||||
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
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
|
||||
@@ -87,6 +87,17 @@ const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
|
||||
"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[] = [
|
||||
"SUBMITTED",
|
||||
"UNDER_REVIEW",
|
||||
@@ -96,9 +107,11 @@ const ALL_STATUSES: LicenseStatus[] = [
|
||||
"INSPECTION_COMPLETED",
|
||||
"ON_HOLD",
|
||||
"APPROVED",
|
||||
...EXAM_ONLY_STATUSES,
|
||||
"PAYMENT_PENDING",
|
||||
"PAID",
|
||||
"PAYMENT_CONFIRMED",
|
||||
"SCHEDULED",
|
||||
"CERTIFICATE_ISSUED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
@@ -117,6 +130,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
||||
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
||||
return type.inspectionRequired;
|
||||
}
|
||||
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
||||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useScheduleExamMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
@@ -209,6 +210,7 @@ export function LicenseReviewPage() {
|
||||
const [issueCertificate] = useIssueCertificateMutation();
|
||||
const [scheduleExam, { isLoading: schedulingExam }] =
|
||||
useScheduleExamMutation();
|
||||
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
||||
const [holdApplication] = useHoldApplicationMutation();
|
||||
const [resumeApplication] = useResumeApplicationMutation();
|
||||
const [escalateApplication] = useEscalateApplicationMutation();
|
||||
@@ -234,6 +236,8 @@ export function LicenseReviewPage() {
|
||||
const [issuanceDate, setIssuanceDate] = useState("");
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||
const [examScore, setExamScore] = useState<number | undefined>();
|
||||
const [findings, setFindings] = useState("");
|
||||
const [checklist, setChecklist] = useState<
|
||||
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
||||
@@ -490,6 +494,11 @@ export function LicenseReviewPage() {
|
||||
case "schedule-exam":
|
||||
setScheduleExamOpen(true);
|
||||
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":
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
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
|
||||
opened={inspectionOpen}
|
||||
onClose={() => setInspectionOpen(false)}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const am: Translations = {
|
||||
allApplications: "ሁሉም ማመልከቻዎች",
|
||||
licenceRegister: "የፈቃድ መዝገብ",
|
||||
certificateDesigner: "የምስክር ወረቀት ንድፍ",
|
||||
certificateRequirements: "የምስክር ወረቀት መስፈርቶች",
|
||||
byType: "በዓይነት",
|
||||
typeFreightForwarder: "የጭነት አስተላላፊ",
|
||||
typeShippingAgent: "የመርከብ ወኪል",
|
||||
@@ -1171,6 +1172,124 @@ export const am: Translations = {
|
||||
noPublishPermission: "ንድፎችን ማተም አይችሉም",
|
||||
},
|
||||
|
||||
certReq: {
|
||||
title: "የምስክር ወረቀት መስፈርቶች",
|
||||
subtitle:
|
||||
"ለፈቃድ ዓይነት አመልካቾች መሙላት ያለባቸውን የቅጽ መስኮች እና የሰነድ ሰቀላዎች ያዋቅሩ፣ መስፈርቱ መቼ ተግባራዊ እንደሚሆንም ጨምሮ።",
|
||||
licenseType: "የፈቃድ ዓይነት",
|
||||
selectLicenseType: "የፈቃድ ዓይነት ይምረጡ",
|
||||
loading: "የፈቃድ ዓይነቶችን በመጫን ላይ…",
|
||||
loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም",
|
||||
tabSchema: "የቅጽ ቅንብር",
|
||||
tabDocuments: "የሰነድ መስፈርቶች",
|
||||
cancel: "ይቅር",
|
||||
delete: "ሰርዝ",
|
||||
saveChanges: "ለውጦችን አስቀምጥ",
|
||||
actionFailed: "ተግባሩ አልተሳካም",
|
||||
condition: {
|
||||
title: "የሚታይበት ሁኔታ",
|
||||
badge: "ሁኔታዊ",
|
||||
enable: "ሁኔታ ሲሟላ ብቻ ተግባራዊ ይሁን",
|
||||
field: "የመስክ መንገድ",
|
||||
fieldHelp: "ወደ ቅጹ የነጥብ መንገድ፣ ለምሳሌ sectionKey.fieldKey",
|
||||
operator: "አመልካች",
|
||||
value: "ዋጋ",
|
||||
values: "ከእነዚህ አንዱ",
|
||||
removeValue: "ለማስወገድ ይጫኑ",
|
||||
isSet: "ተሞልቷል",
|
||||
isNotSet: "አልተሞላም",
|
||||
unknownField: "ይህ መንገድ በአሁኑ ቅንብር ውስጥ ያለ መስክ አይደለም — ቢሆንም እንደተጻፈው ይቀመጣል።",
|
||||
},
|
||||
schema: {
|
||||
subtitle:
|
||||
"አመልካቹ ለዚህ የፈቃድ ዓይነት የሚያየው ክፍሎች እና መስኮች። አንድ ቡድን የሚጋሩ ክፍሎች በአንድ የዊዛርድ ደረጃ ላይ አብረው ይታያሉ።",
|
||||
checkErrors: "ስህተቶችን ፈትሽ",
|
||||
noIssues: "ምንም ችግር አልተገኘም",
|
||||
issuesFound: "ችግሮች ተገኝተዋል",
|
||||
save: "ቅንብር አስቀምጥ",
|
||||
saved: "የቅጽ ቅንብር ተቀምጧል",
|
||||
empty: "እስካሁን ክፍል የለም",
|
||||
emptyBody: "ለዚህ የፈቃድ ዓይነት ቅጽ ለመገንባት ክፍል ይጨምሩ።",
|
||||
},
|
||||
section: {
|
||||
add: "ክፍል ጨምር",
|
||||
edit: "ክፍል አርትዕ",
|
||||
delete: "ክፍል ሰርዝ",
|
||||
deleteConfirm: '"{{name}}"ን እና ሁሉንም መስኮቹን ከዚህ ቅንብር ማስወገድ ይፈልጋሉ?',
|
||||
key: "የክፍል ቁልፍ",
|
||||
keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ",
|
||||
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
|
||||
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
|
||||
title: "ርዕስ",
|
||||
description: "መግለጫ",
|
||||
group: "የዊዛርድ ደረጃ ቡድን",
|
||||
groupHelp: "አንድ ቡድን የሚጋሩ ክፍሎች በአንድ ደረጃ ላይ አብረው ይታያሉ",
|
||||
groupBadge: "ቡድን",
|
||||
groupOrder: "የቡድን ቅደም ተከተል",
|
||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||
},
|
||||
field: {
|
||||
add: "መስክ ጨምር",
|
||||
edit: "መስክ አርትዕ",
|
||||
delete: "መስክ ሰርዝ",
|
||||
deleteConfirm: '"{{name}}"ን ከዚህ ክፍል ማስወገድ ይፈልጋሉ?',
|
||||
key: "የመስክ ቁልፍ",
|
||||
keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ — የቅጽ መረጃ ቁልፍ ይሆናል",
|
||||
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
|
||||
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
|
||||
label: "መለያ",
|
||||
type: "የመስክ ዓይነት",
|
||||
required: "የግድ ያስፈልጋል",
|
||||
placeholder: "ምሳሌ ጽሑፍ",
|
||||
helpText: "የእገዛ ጽሑፍ",
|
||||
min: "ዝቅተኛ",
|
||||
max: "ከፍተኛ",
|
||||
maxLength: "ከፍተኛ ርዝመት",
|
||||
options: "አማራጮች",
|
||||
addOption: "አማራጭ ጨምር",
|
||||
optionValue: "ዋጋ",
|
||||
optionLabel: "መለያ",
|
||||
},
|
||||
doc: {
|
||||
subtitle: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለበት፣ በአዲስ ማመልከቻ እና በዕድሳት የተከፈለ።",
|
||||
add: "የሰነድ መስፈርት ጨምር",
|
||||
edit: "የሰነድ መስፈርት አርትዕ",
|
||||
delete: "የሰነድ መስፈርት ሰርዝ",
|
||||
deleteWarning: "በዚህ ቦታ ላይ የሚተማመኑ አመልካቾች ከዚህ በኋላ አያዩትም። ይህ መመለስ አይቻልም።",
|
||||
deleteConfirm: '"{{name}}"ን ማስወገድ ይፈልጋሉ?',
|
||||
created: "የሰነድ መስፈርት ተጨምሯል",
|
||||
updated: "የሰነድ መስፈርት ተዘምኗል",
|
||||
deleted: "የሰነድ መስፈርት ተወግዷል",
|
||||
loading: "የሰነድ መስፈርቶችን በመጫን ላይ…",
|
||||
loadFailed: "የሰነድ መስፈርቶችን መጫን አልተቻለም",
|
||||
empty: "ምንም የሰነድ መስፈርት አልተዋቀረም",
|
||||
emptyBody: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለባቸውን ሰነዶች ይጨምሩ።",
|
||||
emptyKind: "ለዚህ የማመልከቻ ዓይነት እስካሁን የሰነድ መስፈርት የለም።",
|
||||
key: "ቁልፍ",
|
||||
keyHelp: "ይህን የሰነድ ቦታ የሚለይ ቋሚ መጠሪያ",
|
||||
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
|
||||
keyRequired: "ቁልፍ ያስፈልጋል",
|
||||
name: "ስም",
|
||||
description: "መግለጫ",
|
||||
applicationKind: "የማመልከቻ ዓይነት",
|
||||
kindNew: "አዲስ ማመልከቻ",
|
||||
kindRenewal: "ዕድሳት",
|
||||
kindLocked: "ከተፈጠረ በኋላ የማመልከቻ ዓይነት መቀየር አይቻልም",
|
||||
mode: "ዘዴ",
|
||||
modeAlways: "ሁልጊዜ ያስፈልጋል",
|
||||
modeConditional: "ሁኔታ ሲሟላ ያስፈልጋል",
|
||||
modeOptional: "አማራጭ ስቀላ",
|
||||
conditionRequired: "ሁኔታዊ መስፈርት ሁኔታ ያስፈልገዋል",
|
||||
allowedTypes: "የተፈቀዱ የፋይል ዓይነቶች",
|
||||
maxSize: "ከፍተኛ የፋይል መጠን (MB)",
|
||||
requiresValidity: "የቀን ገደብ ያስፈልጋል",
|
||||
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
|
||||
multiple: "ብዙ",
|
||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||
when: "መቼ",
|
||||
},
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
title: "የመርከበኞች መዝገብ",
|
||||
profileCount_one: "{{count}} መገለጫ",
|
||||
|
||||
@@ -32,6 +32,7 @@ export const en = {
|
||||
allApplications: 'All Applications',
|
||||
licenceRegister: 'Licence Register',
|
||||
certificateDesigner: 'Certificate Designer',
|
||||
certificateRequirements: 'Certificate Requirements',
|
||||
byType: 'By Type',
|
||||
typeFreightForwarder: 'Freight Forwarder',
|
||||
typeShippingAgent: 'Shipping Agent',
|
||||
@@ -1173,6 +1174,124 @@ export const en = {
|
||||
noPublishPermission: 'You cannot publish designs',
|
||||
},
|
||||
|
||||
certReq: {
|
||||
title: 'Certificate requirements',
|
||||
subtitle:
|
||||
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
|
||||
licenseType: 'Licence type',
|
||||
selectLicenseType: 'Select a licence type',
|
||||
loading: 'Loading licence types…',
|
||||
loadFailed: 'Could not load licence types',
|
||||
tabSchema: 'Form schema',
|
||||
tabDocuments: 'Document requirements',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
saveChanges: 'Save changes',
|
||||
actionFailed: 'Action failed',
|
||||
condition: {
|
||||
title: 'Visibility condition',
|
||||
badge: 'conditional',
|
||||
enable: 'Only apply when a condition holds',
|
||||
field: 'Field path',
|
||||
fieldHelp: 'Dot path into the form, e.g. sectionKey.fieldKey',
|
||||
operator: 'Operator',
|
||||
value: 'Value',
|
||||
values: 'Any of',
|
||||
removeValue: 'Click to remove',
|
||||
isSet: 'is set',
|
||||
isNotSet: 'is not set',
|
||||
unknownField: 'This path is not a field in the current schema yet — it will still be saved as typed.',
|
||||
},
|
||||
schema: {
|
||||
subtitle:
|
||||
'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.',
|
||||
checkErrors: 'Check for errors',
|
||||
noIssues: 'No issues found',
|
||||
issuesFound: 'Issues found',
|
||||
save: 'Save schema',
|
||||
saved: 'Form schema saved',
|
||||
empty: 'No sections yet',
|
||||
emptyBody: "Add a section to start building this licence type's form.",
|
||||
},
|
||||
section: {
|
||||
add: 'Add section',
|
||||
edit: 'Edit section',
|
||||
delete: 'Delete section',
|
||||
deleteConfirm: 'Remove "{{name}}" and all of its fields from this schema?',
|
||||
key: 'Section key',
|
||||
keyHelp: 'Letters, numbers and underscores only',
|
||||
keyLocked: 'Key cannot change once created',
|
||||
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
group: 'Wizard step group',
|
||||
groupHelp: 'Sections sharing the same group render together on one step',
|
||||
groupBadge: 'group',
|
||||
groupOrder: 'Group order',
|
||||
sortOrder: 'Sort order',
|
||||
},
|
||||
field: {
|
||||
add: 'Add field',
|
||||
edit: 'Edit field',
|
||||
delete: 'Delete field',
|
||||
deleteConfirm: 'Remove "{{name}}" from this section?',
|
||||
key: 'Field key',
|
||||
keyHelp: 'Letters, numbers and underscores only — becomes the form data key',
|
||||
keyLocked: 'Key cannot change once created',
|
||||
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
|
||||
label: 'Label',
|
||||
type: 'Field type',
|
||||
required: 'Required',
|
||||
placeholder: 'Placeholder',
|
||||
helpText: 'Help text',
|
||||
min: 'Minimum',
|
||||
max: 'Maximum',
|
||||
maxLength: 'Max length',
|
||||
options: 'Options',
|
||||
addOption: 'Add option',
|
||||
optionValue: 'Value',
|
||||
optionLabel: 'Label',
|
||||
},
|
||||
doc: {
|
||||
subtitle: 'What an applicant must upload for this licence type, split by new application and renewal.',
|
||||
add: 'Add document requirement',
|
||||
edit: 'Edit document requirement',
|
||||
delete: 'Delete document requirement',
|
||||
deleteWarning: 'Applicants already relying on this slot will no longer see it. This cannot be undone.',
|
||||
deleteConfirm: 'Remove "{{name}}"?',
|
||||
created: 'Document requirement added',
|
||||
updated: 'Document requirement updated',
|
||||
deleted: 'Document requirement removed',
|
||||
loading: 'Loading document requirements…',
|
||||
loadFailed: 'Could not load document requirements',
|
||||
empty: 'No document requirements configured',
|
||||
emptyBody: 'Add the documents an applicant must upload for this licence type.',
|
||||
emptyKind: 'No document requirements for this application kind yet.',
|
||||
key: 'Key',
|
||||
keyHelp: 'Stable slug identifying this document slot',
|
||||
keyLocked: 'Key cannot change once created',
|
||||
keyRequired: 'Key is required',
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
applicationKind: 'Application kind',
|
||||
kindNew: 'New application',
|
||||
kindRenewal: 'Renewal',
|
||||
kindLocked: 'Application kind cannot change once created',
|
||||
mode: 'Mode',
|
||||
modeAlways: 'Always required',
|
||||
modeConditional: 'Required when condition holds',
|
||||
modeOptional: 'Optional upload',
|
||||
conditionRequired: 'A conditional requirement needs a condition',
|
||||
allowedTypes: 'Allowed file types',
|
||||
maxSize: 'Max file size (MB)',
|
||||
requiresValidity: 'Requires validity dates',
|
||||
allowMultiple: 'Allow multiple uploads',
|
||||
multiple: 'multiple',
|
||||
sortOrder: 'Sort order',
|
||||
when: 'when',
|
||||
},
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
title: 'Seafarer registry',
|
||||
profileCount_one: '{{count}} profile',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconClipboardList,
|
||||
IconClipboardText,
|
||||
IconCreditCard,
|
||||
IconFileDescription,
|
||||
IconFilePlus,
|
||||
@@ -144,6 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
icon: IconRosetteDiscountCheck,
|
||||
permissions: [P.VIEW_TEMPLATES],
|
||||
},
|
||||
{
|
||||
to: '/certificate-requirements',
|
||||
label: 'nav.certificateRequirements',
|
||||
icon: IconClipboardText,
|
||||
permissions: [P.VIEW_LICENSE_TYPES],
|
||||
},
|
||||
{
|
||||
to: '/payment-config',
|
||||
label: 'nav.paymentConfig',
|
||||
|
||||
@@ -46,6 +46,7 @@ import { LicenseRegisterPage } from '../features/license-register/pages/LicenseR
|
||||
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
|
||||
|
||||
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||
@@ -114,6 +115,8 @@ const router = createBrowserRouter([
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
{ path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
|
||||
// Form schema + document requirement authoring, shared by every licence type.
|
||||
{ path: 'certificate-requirements', element: guard([P.VIEW_LICENSE_TYPES], <CertificateRequirementsPage />) },
|
||||
{ path: 'licence-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
|
||||
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
|
||||
// Deep link into the grid with the type facet pinned, so "Freight
|
||||
|
||||
@@ -72,7 +72,8 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'grape',
|
||||
INSPECTION_COMPLETED: 'grape',
|
||||
ELIGIBILITY_APPROVED: 'teal',
|
||||
ELIGIBILITY_PAYMENT_PENDING: 'orange',
|
||||
ELIGIBILITY_PAID: 'blue',
|
||||
EXAM_PAYMENT_PENDING: 'orange',
|
||||
EXAM_PAID: 'blue',
|
||||
EXAM_SCHEDULED: 'indigo',
|
||||
@@ -220,14 +221,25 @@ export function CertificatesPage() {
|
||||
{/* Tooltip needs a hoverable child even while the button itself is
|
||||
disabled, so the reason still shows on hover. */}
|
||||
<span>
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/certificates/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
Apply for CoC / CoP
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
|
||||
disabled={!canApply}
|
||||
>
|
||||
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>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -269,7 +281,7 @@ export function CertificatesPage() {
|
||||
<Text fw={700} mb="md">My Applications</Text>
|
||||
{applications.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -144,6 +144,11 @@ export function DocumentSlots({
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{requirement.description && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{localized(requirement.description)}
|
||||
</Text>
|
||||
)}
|
||||
{existing?.files?.[0] && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{existing.files[0].originalName} ·{' '}
|
||||
|
||||
@@ -7,12 +7,13 @@ interface Props {
|
||||
t: TFunction;
|
||||
requesting: boolean;
|
||||
paying: boolean;
|
||||
onRequestExamFee: (app: LicenseApplication) => void;
|
||||
onRetakeExam: (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
|
||||
* occur on types that examine — folding them into that column would put five
|
||||
@@ -26,24 +27,50 @@ export function ExamStageActions({
|
||||
t,
|
||||
requesting,
|
||||
paying,
|
||||
onRequestExamFee,
|
||||
onRetakeExam,
|
||||
onPay,
|
||||
}: Props) {
|
||||
// Eligible but not yet committed to sitting, or sat and not passed: both are
|
||||
// the same decision — ask for the fee that buys a sitting.
|
||||
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
|
||||
const retake = app.status === 'EXAM_FAILED';
|
||||
// The eligibility fee is invoiced the moment the application is submitted —
|
||||
// there is no separate "request" step, so this is a pay button, exactly
|
||||
// like EXAM_PAYMENT_PENDING below.
|
||||
if (app.status === 'ELIGIBILITY_PAYMENT_PENDING') {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color={retake ? 'orange' : 'teal'}
|
||||
loading={requesting}
|
||||
onClick={() => onRequestExamFee(app)}
|
||||
color="yellow"
|
||||
loading={paying}
|
||||
onClick={() => onPay(app)}
|
||||
>
|
||||
{retake
|
||||
? t('applications.actions.bookRetake', 'Book a resit')
|
||||
: t('applications.actions.bookExam', 'Book exam')}
|
||||
{t('applications.actions.payEligibilityFee', {
|
||||
defaultValue: 'Pay eligibility fee ({{amount}} {{currency}})',
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -737,9 +737,14 @@ export function LicenseApplicationPage() {
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{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)}
|
||||
</Text>
|
||||
{section.description && (
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{localized(section.description)}
|
||||
</Text>
|
||||
)}
|
||||
{locked && (
|
||||
<Alert
|
||||
color="gray"
|
||||
|
||||
@@ -8,7 +8,8 @@ import { ExamStageActions } from '../../components/ExamStageActions';
|
||||
|
||||
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
||||
const EXAM_STAGE_STATUSES = [
|
||||
'ELIGIBILITY_APPROVED',
|
||||
'ELIGIBILITY_PAYMENT_PENDING',
|
||||
'ELIGIBILITY_PAID',
|
||||
'EXAM_PAYMENT_PENDING',
|
||||
'EXAM_PAID',
|
||||
'EXAM_SCHEDULED',
|
||||
@@ -23,12 +24,12 @@ export function applicationActionsColumn(
|
||||
bypassEnabled: boolean;
|
||||
bypassing: 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;
|
||||
onBypass: (app: LicenseApplication) => void;
|
||||
onCertificate: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
onRequestExamFee: (app: LicenseApplication) => void;
|
||||
onRetakeExam: (app: LicenseApplication) => void;
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
@@ -46,14 +47,17 @@ export function applicationActionsColumn(
|
||||
t={t}
|
||||
requesting={deps.requestingExamFee}
|
||||
paying={deps.isPaying}
|
||||
onRequestExamFee={deps.onRequestExamFee}
|
||||
onRetakeExam={deps.onRetakeExam}
|
||||
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. */}
|
||||
{deps.bypassEnabled &&
|
||||
(app.status === 'PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PAYMENT_PENDING') && (
|
||||
app.status === 'ELIGIBILITY_PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PAYMENT_PENDING' ||
|
||||
app.status === 'EXAM_PASSED') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useRequestExamPaymentMutation,
|
||||
useRetakeExamMutation,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
@@ -99,8 +99,7 @@ export function MyApplicationsPage() {
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [requestExamPayment, { isLoading: requestingExamFee }] =
|
||||
useRequestExamPaymentMutation();
|
||||
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
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
|
||||
* the provider the same way every other fee does.
|
||||
*/
|
||||
async function requestExamFee(applicationId: string) {
|
||||
async function retakeExamFee(applicationId: string) {
|
||||
try {
|
||||
await requestExamPayment(applicationId).unwrap();
|
||||
await retakeExam(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
||||
@@ -281,7 +280,7 @@ export function MyApplicationsPage() {
|
||||
onBypass: (app) => handleBypass(app.id),
|
||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||
onPay: (app) => pay(app.id),
|
||||
onRequestExamFee: (app) => requestExamFee(app.id),
|
||||
onRetakeExam: (app) => retakeExamFee(app.id),
|
||||
onOpen: (app) =>
|
||||
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
|
||||
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||
@@ -262,14 +261,9 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/certificates/apply",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
||||
<CoCApplicationPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
// CoC/CoP applications go through the generic license wizard —
|
||||
// /licensing/CERTIFICATE_OF_COMPETENCY/apply and
|
||||
// /licensing/CERTIFICATE_OF_PROFICIENCY/apply, wired below.
|
||||
|
||||
// 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' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'RENEWAL',
|
||||
status: 'ELIGIBILITY_APPROVED',
|
||||
status: 'ELIGIBILITY_PAID',
|
||||
assignedOfficerId: 'officer-mock-002',
|
||||
claimedAt: '2026-08-01T10:00:00.000Z',
|
||||
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
ApplicationPayment,
|
||||
ApplicationStaff,
|
||||
Attachment,
|
||||
DocumentRequirement,
|
||||
FormSchemaPalette,
|
||||
FormSectionConfig,
|
||||
InitiatePaymentResult,
|
||||
IssuedLicense,
|
||||
Inspection,
|
||||
@@ -25,6 +28,7 @@ import type {
|
||||
QueueFilter,
|
||||
RemarkTargetType,
|
||||
SavedQueueView,
|
||||
SchemaIssue,
|
||||
TemplateFieldPlacement,
|
||||
TemplateLogoPlacement,
|
||||
TemplatePageOptions,
|
||||
@@ -66,6 +70,7 @@ const TAGS = [
|
||||
'License',
|
||||
'SavedView',
|
||||
'LicenseTemplate',
|
||||
'DocumentRequirement',
|
||||
] 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)],
|
||||
}),
|
||||
|
||||
// ------------------------------------------------ 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
|
||||
createApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
@@ -494,9 +569,26 @@ export const licensingApi = baseApi
|
||||
scheduleExam: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ 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 }) => ({
|
||||
url: `/license-application-review/${id}/schedule-exam`,
|
||||
url: `/license-application-review/${id}/exam-outcome`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
@@ -505,12 +597,13 @@ export const licensingApi = baseApi
|
||||
}),
|
||||
|
||||
/**
|
||||
* Raises the examination fee — after eligibility approval, or again when
|
||||
* a failed candidate elects to resit.
|
||||
* A failed candidate asks for another sitting. Re-opens the examination
|
||||
* 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) => ({
|
||||
url: `/license-applications/${id}/request-exam-payment`,
|
||||
url: `/license-applications/${id}/retake`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
@@ -809,6 +902,13 @@ export const {
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
useUpdateFormSchemaMutation,
|
||||
useValidateFormSchemaMutation,
|
||||
useGetFormSchemaPaletteQuery,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useCreateDocumentRequirementMutation,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useCreateApplicationMutation,
|
||||
@@ -864,7 +964,8 @@ export const {
|
||||
useFinalApproveMutation,
|
||||
useRejectApplicationMutation,
|
||||
useScheduleExamMutation,
|
||||
useRequestExamPaymentMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useRetakeExamMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
|
||||
@@ -75,7 +75,8 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
SCHEDULED: 'Pickup Scheduled',
|
||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||
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_PAID: 'Awaiting Exam Date',
|
||||
EXAM_SCHEDULED: 'Exam Scheduled',
|
||||
@@ -101,7 +102,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
SCHEDULED: 'cyan',
|
||||
CERTIFICATE_ISSUED: 'green',
|
||||
COMPLETED: 'green',
|
||||
ELIGIBILITY_APPROVED: 'teal',
|
||||
ELIGIBILITY_PAYMENT_PENDING: 'yellow',
|
||||
ELIGIBILITY_PAID: 'lime',
|
||||
EXAM_PAYMENT_PENDING: 'yellow',
|
||||
EXAM_PAID: 'lime',
|
||||
EXAM_SCHEDULED: 'cyan',
|
||||
@@ -136,7 +138,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
REJECTED: 100,
|
||||
// The exam leg sits between approval and the certificate fee, so these
|
||||
// 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_PAID: 68,
|
||||
EXAM_SCHEDULED: 72,
|
||||
@@ -150,6 +153,9 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
||||
'DRAFT',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'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
|
||||
// sit again after a failure.
|
||||
'EXAM_PAYMENT_PENDING',
|
||||
|
||||
@@ -41,9 +41,11 @@ export type LicenseStatus =
|
||||
| "SCHEDULED"
|
||||
| "CERTIFICATE_ISSUED"
|
||||
| "COMPLETED"
|
||||
// Examined certificates (CoC, some CoP): approval establishes eligibility,
|
||||
// the candidate pays to sit, and the certificate fee falls due on a pass.
|
||||
| "ELIGIBILITY_APPROVED"
|
||||
// Examined certificates (CoC, some CoP): the eligibility assessment fee is
|
||||
// due before review starts, then the candidate pays to sit, and the
|
||||
// certificate fee falls due on a pass.
|
||||
| "ELIGIBILITY_PAYMENT_PENDING"
|
||||
| "ELIGIBILITY_PAID"
|
||||
| "EXAM_PAYMENT_PENDING"
|
||||
| "EXAM_PAID"
|
||||
| "EXAM_SCHEDULED"
|
||||
@@ -77,10 +79,12 @@ export interface FormFieldConfig {
|
||||
label: Bilingual;
|
||||
type: FormFieldType;
|
||||
required?: boolean;
|
||||
placeholder?: Bilingual;
|
||||
helpText?: Bilingual;
|
||||
options?: { value: string; label: Bilingual }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
maxLength?: number;
|
||||
showWhen?: FieldCondition;
|
||||
readOnly?: boolean;
|
||||
source?: string;
|
||||
@@ -233,6 +237,7 @@ export interface StcwCapacityRow {
|
||||
|
||||
export interface DocumentRequirement {
|
||||
id: string;
|
||||
licenseTypeId: string;
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
@@ -242,7 +247,32 @@ export interface DocumentRequirement {
|
||||
allowedMimeTypes: string[];
|
||||
maxSizeMb: number;
|
||||
requiresValidityDates: boolean;
|
||||
allowMultiple: boolean;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user