mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
- 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.
221 lines
8.1 KiB
TypeScript
221 lines
8.1 KiB
TypeScript
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>
|
||
);
|
||
}
|