diff --git a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx index 8dec84dfe..5c19085fa 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -1,13 +1,17 @@ import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; 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'; interface Props { licenseTypes: LicenseType[]; typeId: string | null; 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; onValidityChange: (months: number) => void; currentValidityMonths?: number | null; @@ -22,6 +26,9 @@ export function DesignerToolbar({ licenseTypes, typeId, onTypeChange, + ranks, + rankId, + onRankChange, validityMonths, onValidityChange, currentValidityMonths, @@ -49,6 +56,23 @@ export function DesignerToolbar({ 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 && ( + ({ value: op, label: op }))} + value={operator} + onChange={(v) => v && setOperator(v as Operator)} + allowDeselect={false} + /> + + {operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && ( + ({ + 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' && ( + + setInValues( + e.currentTarget.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + /> + )} + + + {operator === 'in' && (value.in?.length ?? 0) > 0 && ( + + {(value.in ?? []).map((v, i) => ( + setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))} + title={t('certReq.condition.removeValue', 'Click to remove')} + > + {String(v)} × + + ))} + + )} + + {!target && value.field && ( + + {t( + 'certReq.condition.unknownField', + 'This path is not a field in the current schema yet — it will still be saved as typed.', + )} + + )} + + ); +} + +const EMPTY_ARM: ConditionArm = { field: '', equals: '' }; + /** * Authors one `FieldCondition` (`showWhen` on a section/field, or * `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 * 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, @@ -54,40 +253,35 @@ export function ConditionBuilder({ 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']; + const isAnyOf = Boolean(value?.anyOf); + const arms = (value?.anyOf ?? []) as ConditionArm[]; - function setField(field: string) { - onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) }); + function setArm(i: number, arm: ConditionArm) { + const next = arms.slice(); + next[i] = arm; + onChange({ anyOf: next }); } - 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 addArm() { + onChange({ anyOf: [...arms, { ...EMPTY_ARM }] }); } - 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 removeArm(i: number) { + onChange({ anyOf: arms.filter((_, idx) => idx !== i) }); } - function setInValues(raws: string[]) { - if (!value?.field) return; - onChange({ - field: value.field, - in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[], - }); + 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 ( @@ -102,116 +296,58 @@ export function ConditionBuilder({ {active && ( - c.path)} - value={value?.field ?? ''} - onChange={setField} + checked={isAnyOf} + onChange={(e) => toggleAnyOf(e.currentTarget.checked)} /> - - ({ - 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' ? ( - setValueRaw(String(e.currentTarget.checked))} - /> - ) : ( - setValueRaw(e.currentTarget.value)} - /> - ) - )} - - {operator === 'in' && target?.field.type === 'SELECT' && ( -