Files
emaui/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx

357 lines
12 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 { ActionIcon, Autocomplete, Checkbox, Group, Paper, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
import { IconPlus, IconTrash } from '@tabler/icons-react';
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 };
/** One editable `anyOf` arm — a single-field condition, same shape a plain condition holds. */
type ConditionArm = Omit<ConditionValue, 'anyOf' | 'previousDocExpired'>;
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;
}
/** One-line summary of a condition for read-only chips ("when X = Y", "when X = Y or W = Z"). */
export function describeCondition(
condition: ConditionValue,
t: (key: string, fallback: string) => string,
): string {
if (condition.anyOf?.length) {
return condition.anyOf.map((arm) => describeCondition(arm, t)).join(` ${t('certReq.condition.or', 'or')} `);
}
if (!condition.field) return '';
const parts = [condition.field];
if (condition.equals !== undefined) parts.push(`= ${condition.equals}`);
if (condition.notEquals !== undefined) parts.push(`${condition.notEquals}`);
if (condition.in !== undefined) parts.push(`∈ [${condition.in.join(', ')}]`);
if (condition.isSet !== undefined) {
parts.push(condition.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'));
}
return parts.join(' ');
}
/**
* The field/operator/value trio for one condition — a plain condition, or one
* arm of an `anyOf`. No enable switch of its own; the caller owns whether
* this row exists at all.
*/
function ConditionArmFields({
value,
onChange,
targets,
palette,
}: {
value: ConditionArm;
onChange: (value: ConditionArm) => void;
targets: ConditionTarget[];
palette: FormSchemaPalette | undefined;
}) {
const { t } = useTranslation();
const localized = useLocalized();
const operator = operatorOf(value) ?? '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: ConditionArm = { 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">
<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-default-hover)"
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>
);
}
const EMPTY_ARM: ConditionArm = { field: '', equals: '' };
/**
* 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.
*
* "Any of these" switches to authoring several single-field conditions whose
* OR is the real condition — needed when the same logical value can live on
* one of several mutually-exclusive fields (e.g. a rank split by
* department, see FieldCondition.anyOf).
*/
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 active = value !== null;
const isAnyOf = Boolean(value?.anyOf);
const arms = (value?.anyOf ?? []) as ConditionArm[];
function setArm(i: number, arm: ConditionArm) {
const next = arms.slice();
next[i] = arm;
onChange({ anyOf: next });
}
function addArm() {
onChange({ anyOf: [...arms, { ...EMPTY_ARM }] });
}
function removeArm(i: number) {
onChange({ anyOf: arms.filter((_, idx) => idx !== i) });
}
function toggleAnyOf(next: boolean) {
if (next) {
// Seed the list from whatever single condition already existed, so
// switching modes doesn't discard work in progress.
const seed: ConditionArm = value?.field ? (value as ConditionArm) : { ...EMPTY_ARM };
onChange({ anyOf: [seed] });
} else {
// Same, in reverse — the first arm becomes the single condition.
onChange((arms[0] as ConditionValue) ?? { field: '', equals: '' });
}
}
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}>
<Switch
size="sm"
label={t(
'certReq.condition.anyOfEnable',
'Any of these (the value can live on one of several fields)',
)}
checked={isAnyOf}
onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
/>
{isAnyOf ? (
<Stack gap="sm">
{arms.map((arm, i) => (
<Paper key={i} withBorder p="sm" radius="sm">
<Group justify="space-between" mb="xs">
<Text fz="xs" fw={600} c="dimmed">
{t('certReq.condition.anyOfArm', 'Condition {{n}}', { n: i + 1 })}
</Text>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={arms.length <= 1}
onClick={() => removeArm(i)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
<ConditionArmFields
value={arm}
onChange={(next) => setArm(i, next)}
targets={targets}
palette={palette}
/>
</Paper>
))}
<Group>
<ActionIcon variant="light" onClick={addArm}>
<IconPlus size={16} />
</ActionIcon>
<Text fz="xs" c="dimmed">
{t('certReq.condition.anyOfAdd', 'Add another field')}
</Text>
</Group>
</Stack>
) : (
<ConditionArmFields
value={value as ConditionArm}
onChange={(next) => onChange(next)}
targets={targets}
palette={palette}
/>
)}
</Stack>
)}
</Stack>
);
}