mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Add rank selection and condition handling to certificate designer and requirements
This commit is contained in:
@@ -1,13 +1,17 @@
|
|||||||
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
|
||||||
import { IconPlus } from '@tabler/icons-react';
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalized, type LicenseType } from '@ema-platform/api';
|
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
|
||||||
import { groupedTypeOptions } from '../config/designer';
|
import { groupedTypeOptions } from '../config/designer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
licenseTypes: LicenseType[];
|
licenseTypes: LicenseType[];
|
||||||
typeId: string | null;
|
typeId: string | null;
|
||||||
onTypeChange: (id: string | null) => void;
|
onTypeChange: (id: string | null) => void;
|
||||||
|
/** The selected licence type's rank ladder — empty for non-CoC/CoP types. */
|
||||||
|
ranks: Rank[];
|
||||||
|
rankId: string | null;
|
||||||
|
onRankChange: (id: string | null) => void;
|
||||||
validityMonths: number;
|
validityMonths: number;
|
||||||
onValidityChange: (months: number) => void;
|
onValidityChange: (months: number) => void;
|
||||||
currentValidityMonths?: number | null;
|
currentValidityMonths?: number | null;
|
||||||
@@ -22,6 +26,9 @@ export function DesignerToolbar({
|
|||||||
licenseTypes,
|
licenseTypes,
|
||||||
typeId,
|
typeId,
|
||||||
onTypeChange,
|
onTypeChange,
|
||||||
|
ranks,
|
||||||
|
rankId,
|
||||||
|
onRankChange,
|
||||||
validityMonths,
|
validityMonths,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
currentValidityMonths,
|
currentValidityMonths,
|
||||||
@@ -49,6 +56,23 @@ export function DesignerToolbar({
|
|||||||
w={340}
|
w={340}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* CoC/CoP only — a rank can carry its own design (e.g. Master's
|
||||||
|
certificate differs from an OOW's). "Default" (null) is the design
|
||||||
|
every other rank under the type falls back to. */}
|
||||||
|
{ranks.length > 0 && (
|
||||||
|
<Select
|
||||||
|
label={t('designer.rank', 'Rank')}
|
||||||
|
description={t('designer.rankHint', 'Leave as Default to design for every rank')}
|
||||||
|
data={[
|
||||||
|
{ value: '', label: t('designer.rankDefault', 'Default (all ranks)') },
|
||||||
|
...ranks.map((r) => ({ value: r.id, label: localized(r.name) })),
|
||||||
|
]}
|
||||||
|
value={rankId ?? ''}
|
||||||
|
onChange={(value) => onRankChange(value || null)}
|
||||||
|
w={220}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Validity lives beside the design because it is the other half of
|
{/* Validity lives beside the design because it is the other half of
|
||||||
what a certificate promises. */}
|
what a certificate promises. */}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ import {
|
|||||||
useArchiveLicenseTemplateMutation,
|
useArchiveLicenseTemplateMutation,
|
||||||
useCreateLicenseTemplateMutation,
|
useCreateLicenseTemplateMutation,
|
||||||
useDeleteLicenseTemplateMutation,
|
useDeleteLicenseTemplateMutation,
|
||||||
|
useGetActiveDepartmentsQuery,
|
||||||
useGetBuiltInTemplateQuery,
|
useGetBuiltInTemplateQuery,
|
||||||
useGetLicenseTemplatesQuery,
|
useGetLicenseTemplatesQuery,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
|
useGetRankLadderQuery,
|
||||||
useGetTemplateVariablesQuery,
|
useGetTemplateVariablesQuery,
|
||||||
usePublishLicenseTemplateMutation,
|
usePublishLicenseTemplateMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
@@ -66,14 +68,19 @@ export function CertificateDesignerPage() {
|
|||||||
|
|
||||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||||
const [typeId, setTypeId] = useState<string | null>(null);
|
const [typeId, setTypeId] = useState<string | null>(null);
|
||||||
|
const [rankId, setRankId] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: templates = [],
|
data: allTemplates = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
|
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
|
||||||
|
// The list is per licence type; a rank-specific design and the type's
|
||||||
|
// default both come back, so the version list is scoped to whichever the
|
||||||
|
// toolbar has selected.
|
||||||
|
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
|
||||||
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
||||||
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
||||||
|
|
||||||
@@ -95,6 +102,20 @@ export function CertificateDesignerPage() {
|
|||||||
|
|
||||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||||
|
|
||||||
|
// A rank ladder only exists for CoC/CoP — every other licence type designs
|
||||||
|
// one certificate for everyone who holds it.
|
||||||
|
const isRankScoped =
|
||||||
|
selectedType?.certificateCategory === 'COC' || selectedType?.certificateCategory === 'COP';
|
||||||
|
const { data: departments } = useGetActiveDepartmentsQuery(undefined, { skip: !isRankScoped });
|
||||||
|
const department = departments?.find((d) => d.code === selectedType?.stcwDepartment);
|
||||||
|
const { data: ranks = [] } = useGetRankLadderQuery(
|
||||||
|
{
|
||||||
|
departmentId: department?.id ?? '',
|
||||||
|
certificateCategory: (selectedType?.certificateCategory ?? 'COC') as 'COC' | 'COP',
|
||||||
|
},
|
||||||
|
{ skip: !isRankScoped || !department },
|
||||||
|
);
|
||||||
|
|
||||||
// Default to the first licence type so the page is never an empty shell.
|
// Default to the first licence type so the page is never an empty shell.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
||||||
@@ -104,6 +125,12 @@ export function CertificateDesignerPage() {
|
|||||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||||
}, [selectedType]);
|
}, [selectedType]);
|
||||||
|
|
||||||
|
// Switching licence type leaves a stale rank selected from the previous
|
||||||
|
// type's ladder — reset to the type's default design.
|
||||||
|
useEffect(() => {
|
||||||
|
setRankId(null);
|
||||||
|
}, [typeId]);
|
||||||
|
|
||||||
function startNewVersion() {
|
function startNewVersion() {
|
||||||
setNewName(
|
setNewName(
|
||||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||||
@@ -130,6 +157,12 @@ export function CertificateDesignerPage() {
|
|||||||
setTypeId(value);
|
setTypeId(value);
|
||||||
draft.setSelectedId(null);
|
draft.setSelectedId(null);
|
||||||
}}
|
}}
|
||||||
|
ranks={ranks}
|
||||||
|
rankId={rankId}
|
||||||
|
onRankChange={(value) => {
|
||||||
|
setRankId(value);
|
||||||
|
draft.setSelectedId(null);
|
||||||
|
}}
|
||||||
validityMonths={validityMonths}
|
validityMonths={validityMonths}
|
||||||
onValidityChange={setValidityMonths}
|
onValidityChange={setValidityMonths}
|
||||||
currentValidityMonths={selectedType?.validityMonths}
|
currentValidityMonths={selectedType?.validityMonths}
|
||||||
@@ -369,6 +402,7 @@ export function CertificateDesignerPage() {
|
|||||||
run(async () => {
|
run(async () => {
|
||||||
const created = await createTemplate({
|
const created = await createTemplate({
|
||||||
licenseTypeId: typeId as string,
|
licenseTypeId: typeId as string,
|
||||||
|
rankId,
|
||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
|
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
|
||||||
import { useLocalized } from '@ema-platform/api';
|
import { useLocalized } from '@ema-platform/api';
|
||||||
@@ -7,6 +8,9 @@ import type { ConditionTarget } from '../config/schema-paths';
|
|||||||
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
|
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
|
||||||
export type ConditionValue = FieldCondition & { previousDocExpired?: string };
|
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';
|
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
|
||||||
|
|
||||||
function operatorOf(condition: ConditionValue | undefined): Operator | null {
|
function operatorOf(condition: ConditionValue | undefined): Operator | null {
|
||||||
@@ -29,6 +33,196 @@ function coerce(raw: string, targetType: string | undefined): string | number |
|
|||||||
return 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-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_ARM: ConditionArm = { field: '', equals: '' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authors one `FieldCondition` (`showWhen` on a section/field, or
|
* Authors one `FieldCondition` (`showWhen` on a section/field, or
|
||||||
* `conditionExpression` on a document requirement).
|
* `conditionExpression` on a document requirement).
|
||||||
@@ -38,6 +232,11 @@ function coerce(raw: string, targetType: string | undefined): string | number |
|
|||||||
* SELECT field, the value picker switches to that field's own options
|
* SELECT field, the value picker switches to that field's own options
|
||||||
* instead of free text — the condition can only ever reference an answer
|
* instead of free text — the condition can only ever reference an answer
|
||||||
* that could actually be chosen.
|
* 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({
|
export function ConditionBuilder({
|
||||||
value,
|
value,
|
||||||
@@ -54,40 +253,35 @@ export function ConditionBuilder({
|
|||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const localized = useLocalized();
|
|
||||||
|
|
||||||
const active = value !== null;
|
const active = value !== null;
|
||||||
const operator = operatorOf(value ?? undefined) ?? 'equals';
|
const isAnyOf = Boolean(value?.anyOf);
|
||||||
const target = targets.find((c) => c.path === value?.field);
|
const arms = (value?.anyOf ?? []) as ConditionArm[];
|
||||||
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
|
|
||||||
|
|
||||||
function setField(field: string) {
|
function setArm(i: number, arm: ConditionArm) {
|
||||||
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
|
const next = arms.slice();
|
||||||
|
next[i] = arm;
|
||||||
|
onChange({ anyOf: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOperator(next: Operator) {
|
function addArm() {
|
||||||
if (!value?.field) return;
|
onChange({ anyOf: [...arms, { ...EMPTY_ARM }] });
|
||||||
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) {
|
function removeArm(i: number) {
|
||||||
if (!value?.field) return;
|
onChange({ anyOf: arms.filter((_, idx) => idx !== i) });
|
||||||
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[]) {
|
function toggleAnyOf(next: boolean) {
|
||||||
if (!value?.field) return;
|
if (next) {
|
||||||
onChange({
|
// Seed the list from whatever single condition already existed, so
|
||||||
field: value.field,
|
// switching modes doesn't discard work in progress.
|
||||||
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
|
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 (
|
return (
|
||||||
@@ -102,116 +296,58 @@ export function ConditionBuilder({
|
|||||||
|
|
||||||
{active && (
|
{active && (
|
||||||
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
|
<Stack gap="xs" pl={allowClear ? 'md' : 0}>
|
||||||
<Autocomplete
|
<Switch
|
||||||
label={t('certReq.condition.field', 'Field path')}
|
size="sm"
|
||||||
placeholder="certificate.rank"
|
label={t(
|
||||||
description={t(
|
'certReq.condition.anyOfEnable',
|
||||||
'certReq.condition.fieldHelp',
|
'Any of these (the value can live on one of several fields)',
|
||||||
'Dot path into the form, e.g. sectionKey.fieldKey',
|
|
||||||
)}
|
)}
|
||||||
data={targets.map((c) => c.path)}
|
checked={isAnyOf}
|
||||||
value={value?.field ?? ''}
|
onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
|
||||||
onChange={setField}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
{isAnyOf ? (
|
||||||
<Select
|
<Stack gap="sm">
|
||||||
label={t('certReq.condition.operator', 'Operator')}
|
{arms.map((arm, i) => (
|
||||||
data={operators.map((op) => ({ value: op, label: op }))}
|
<Paper key={i} withBorder p="sm" radius="sm">
|
||||||
value={operator}
|
<Group justify="space-between" mb="xs">
|
||||||
onChange={(v) => v && setOperator(v as Operator)}
|
<Text fz="xs" fw={600} c="dimmed">
|
||||||
allowDeselect={false}
|
{t('certReq.condition.anyOfArm', 'Condition {{n}}', { n: i + 1 })}
|
||||||
/>
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
|
variant="subtle"
|
||||||
<Select
|
color="red"
|
||||||
label={t('certReq.condition.value', 'Value')}
|
size="sm"
|
||||||
data={(target.field.options ?? []).map((o) => ({
|
disabled={arms.length <= 1}
|
||||||
value: o.value,
|
onClick={() => removeArm(i)}
|
||||||
label: localized(o.label) || o.value,
|
>
|
||||||
}))}
|
<IconTrash size={14} />
|
||||||
value={String(value?.equals ?? value?.notEquals ?? '')}
|
</ActionIcon>
|
||||||
onChange={(v) => v !== null && setValueRaw(v)}
|
</Group>
|
||||||
/>
|
<ConditionArmFields
|
||||||
)}
|
value={arm}
|
||||||
|
onChange={(next) => setArm(i, next)}
|
||||||
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
|
targets={targets}
|
||||||
target?.field.type === 'BOOLEAN' ? (
|
palette={palette}
|
||||||
<Checkbox
|
/>
|
||||||
mt="xl"
|
</Paper>
|
||||||
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>
|
<Group>
|
||||||
)}
|
<ActionIcon variant="light" onClick={addArm}>
|
||||||
|
<IconPlus size={16} />
|
||||||
{!target && value?.field && (
|
</ActionIcon>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{t(
|
{t('certReq.condition.anyOfAdd', 'Add another field')}
|
||||||
'certReq.condition.unknownField',
|
</Text>
|
||||||
'This path is not a field in the current schema yet — it will still be saved as typed.',
|
</Group>
|
||||||
)}
|
</Stack>
|
||||||
</Text>
|
) : (
|
||||||
|
<ConditionArmFields
|
||||||
|
value={value as ConditionArm}
|
||||||
|
onChange={(next) => onChange(next)}
|
||||||
|
targets={targets}
|
||||||
|
palette={palette}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!draft.name.en?.trim()) return;
|
if (!draft.name.en?.trim()) return;
|
||||||
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) {
|
const hasCondition =
|
||||||
|
Boolean(draft.conditionExpression?.field) ||
|
||||||
|
Boolean(draft.conditionExpression?.anyOf?.length);
|
||||||
|
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
|
||||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { collectConditionTargets } from '../config/schema-paths';
|
import { collectConditionTargets } from '../config/schema-paths';
|
||||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||||
|
import { describeCondition } from './ConditionBuilder';
|
||||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||||
|
|
||||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
||||||
@@ -137,13 +138,9 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
|||||||
<Text fz="xs" c="dimmed" truncate>
|
<Text fz="xs" c="dimmed" truncate>
|
||||||
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
|
{req.mode === 'CONDITIONAL' && req.conditionExpression && (
|
||||||
<Text fz="xs" c="violet">
|
<Text fz="xs" c="violet">
|
||||||
{t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '}
|
{t('certReq.doc.when', 'when')} {describeCondition(req.conditionExpression, t)}
|
||||||
{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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -558,14 +558,25 @@ export function validateSections(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Evaluates a config condition against the current form answers. */
|
/** Evaluates a config condition against the current form answers. */
|
||||||
|
interface ConditionLike {
|
||||||
|
field?: string;
|
||||||
|
equals?: unknown;
|
||||||
|
notEquals?: unknown;
|
||||||
|
in?: (string | number)[];
|
||||||
|
isSet?: boolean;
|
||||||
|
/** Holds when ANY listed sub-condition holds — see FieldCondition.anyOf. */
|
||||||
|
anyOf?: ConditionLike[];
|
||||||
|
}
|
||||||
|
|
||||||
export function conditionHolds(
|
export function conditionHolds(
|
||||||
condition:
|
condition: ConditionLike | undefined | null,
|
||||||
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
|
|
||||||
| undefined
|
|
||||||
| null,
|
|
||||||
formData: Record<string, Record<string, unknown>>,
|
formData: Record<string, Record<string, unknown>>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!condition?.field) return true;
|
if (!condition) return true;
|
||||||
|
if (condition.anyOf) {
|
||||||
|
return condition.anyOf.some((sub) => conditionHolds(sub, formData));
|
||||||
|
}
|
||||||
|
if (!condition.field) return true;
|
||||||
const value = condition.field
|
const value = condition.field
|
||||||
.split('.')
|
.split('.')
|
||||||
.reduce<unknown>(
|
.reduce<unknown>(
|
||||||
|
|||||||
@@ -67,11 +67,18 @@ export type FormFieldType =
|
|||||||
| "TIN";
|
| "TIN";
|
||||||
|
|
||||||
export interface FieldCondition {
|
export interface FieldCondition {
|
||||||
field: string;
|
/** Omitted when `anyOf` is used instead — see below. */
|
||||||
|
field?: string;
|
||||||
equals?: string | number | boolean;
|
equals?: string | number | boolean;
|
||||||
notEquals?: string | number | boolean;
|
notEquals?: string | number | boolean;
|
||||||
in?: (string | number)[];
|
in?: (string | number)[];
|
||||||
isSet?: boolean;
|
isSet?: boolean;
|
||||||
|
/**
|
||||||
|
* Holds when ANY listed condition holds — for a value that can live on one
|
||||||
|
* of several mutually-exclusive fields (e.g. a rank split by department).
|
||||||
|
* `field`/`equals`/etc are ignored when this is present.
|
||||||
|
*/
|
||||||
|
anyOf?: FieldCondition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormFieldConfig {
|
export interface FormFieldConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user