Files
emaui/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx
Nati 357dc3e4d5 feat: add document requirements and form schema management for license types
- Implement DocumentRequirementsTab for managing document upload requirements.
- Create FieldEditorDrawer and SectionEditorDrawer for editing fields and sections in the form schema.
- Develop FormSchemaTab to handle sections and fields for a license type's form schema.
- Introduce CertificateRequirementsPage to serve as the main interface for configuring license type requirements.
- Add hooks for requirement actions to streamline mutation handling and notifications.
- Implement condition handling for fields and sections to manage visibility based on user input.
- Create schema-paths configuration to facilitate condition target collection.
2026-08-20 13:37:26 +00:00

221 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}