Несколько условий шага, объединяемых через И/ИЛИ (#16)
Tests / frontend-test (push) Successful in 47s
Tests / backend-test (push) Successful in 2m12s

<html>
<body>
<!--StartFragment-->
GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors
--

<!--EndFragment-->
</body>
</html>[GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors](http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/commit/572749b4d7cc11bc8e1ef4184a93f18a14b710ab)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/16
This commit was merged in pull request #16.
This commit is contained in:
2026-08-25 07:04:44 +00:00
parent e4a6cb8167
commit ba48ed1a0e
27 changed files with 1622 additions and 413 deletions
@@ -44,9 +44,8 @@ function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
headType: step.headType,
status: step.status,
allowReassignToOtherApprovers: step.allowReassignToOtherApprovers,
fieldName: step.fieldName,
operation: step.operation,
value: step.value,
conditions: step.conditions,
conditionsLogic: step.conditionsLogic,
slaHours: step.slaHours,
askOverdueReason: step.askOverdueReason
};
@@ -1,5 +1,27 @@
@import '../../../../styles/design-tokens';
// Список условий появления шага — своя мини-форма после основной сетки полей
// (тот же приём, что и в ResolveScreen для списка проверяемых значений).
.conditions {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid $gray-200;
}
.conditionRow {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.conditionsHint {
margin: -4px 0 10px;
font-size: 11.5px;
color: $gray-400;
}
// Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри
// Field, поэтому отступ сверху небольшой — визуально это продолжение поля,
// а не отдельный блок.
@@ -2,23 +2,32 @@ import * as React from 'react';
import styles from './RouteStepFormModal.module.scss';
import {
ComparisonOperator,
ConditionsLogic,
Employee,
HeadType,
ProjectRole,
RequestType,
RouteStepCondition,
RouteStepStatus,
StepExecutorKind,
TemplateRoute,
TemplateRouteStep
} from '../../models';
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import {
comparisonOperatorLabels,
conditionsLogicLabels,
headTypeLabels,
projectRoleLabels,
routeStepStatusLabels,
texts
} from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor';
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
import { Button, Checkbox, Field, FormGrid, FormModal, IconButton, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
import {
boolToTriState,
isValidSlaHours,
@@ -63,14 +72,15 @@ interface IFormState {
headType: HeadType;
allowReassignToOtherApprovers: boolean;
hasCondition: boolean;
fieldName: string;
operation: ComparisonOperator;
value: string;
conditions: RouteStepCondition[];
conditionsLogic: ConditionsLogic;
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
slaHours: string;
askOverdueReason: TriState;
}
const EMPTY_CONDITION: RouteStepCondition = { fieldName: '', operation: ComparisonOperator.GreaterThan, value: '' };
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
@@ -86,6 +96,8 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
const CONDITIONS_LOGIC_OPTIONS = Object.values(ConditionsLogic).map(v => ({ value: v, label: conditionsLogicLabels[v] }));
/** «Больше»/«меньше» сравнивают числа — для текстовой колонки они бессмысленны. */
const NUMERIC_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.GreaterThan, ComparisonOperator.LessThan];
@@ -118,9 +130,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
headType: HeadType.Chief,
allowReassignToOtherApprovers: false,
hasCondition: false,
fieldName: '',
operation: ComparisonOperator.GreaterThan,
value: '',
conditions: [EMPTY_CONDITION],
conditionsLogic: ConditionsLogic.And,
slaHours: '',
askOverdueReason: ''
};
@@ -134,10 +145,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
hasCondition: Boolean(editing.fieldName),
fieldName: editing.fieldName ?? '',
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
value: editing.value ?? '',
hasCondition: editing.conditions.length > 0,
conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION],
conditionsLogic: editing.conditionsLogic,
slaHours: slaHoursToInput(editing.slaHours),
askOverdueReason: boolToTriState(editing.askOverdueReason)
};
@@ -162,59 +172,61 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
const hasListId = Boolean(requestType?.listId);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
const fieldNameOptions = React.useMemo(() => {
// currentValue — значение конкретной строки условия: если оно не входит в
// подгруженные поля списка (набрано до появления поля в списке или после его
// удаления), добавляем его отдельным пунктом, чтобы не сбрасывать выбор молча.
const fieldNameOptionsFor = (currentValue: string): { value: string; label: string }[] => {
const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions];
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
options.push({ value: form.fieldName, label: form.fieldName });
if (currentValue && !options.some(o => o.value === currentValue)) {
options.push({ value: currentValue, label: currentValue });
}
return options;
}, [sharePointFields.items, form.fieldName]);
const isManagerField = form.fieldName === IS_MANAGER_FIELD;
};
// Тип столбца известен, только если он есть среди подгруженных полей списка —
// для значения, введённого до появления поля в списке (или после его удаления),
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
// IsManager — служебное булево поле, а не колонка списка, поэтому для него
// числовые ограничения не действуют в любом случае.
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
const fieldKind: 'bool' | 'numeric' | 'text' | 'unknown' = isManagerField
? 'bool'
: !selectedField
? 'unknown'
: isNumericFieldType(selectedField.typeAsString)
? 'numeric'
: 'text';
const isNumericField = fieldKind === 'numeric' || fieldKind === 'unknown';
const fieldKindOf = (fieldName: string): 'bool' | 'numeric' | 'text' | 'unknown' => {
if (fieldName === IS_MANAGER_FIELD) return 'bool';
const field = sharePointFields.items.find(f => f.internalName === fieldName);
if (!field) return 'unknown';
return isNumericFieldType(field.typeAsString) ? 'numeric' : 'text';
};
const isNumericFieldKind = (kind: ReturnType<typeof fieldKindOf>): boolean => kind === 'numeric' || kind === 'unknown';
// Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше».
// Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем.
const operationOptions = React.useMemo(() => OPERATION_OPTIONS.filter(o => {
if (fieldKind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual;
if (fieldKind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0;
if (fieldKind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0;
return true;
}), [fieldKind]);
const operationOptionsFor = (kind: ReturnType<typeof fieldKindOf>): typeof OPERATION_OPTIONS =>
OPERATION_OPTIONS.filter(o => {
if (kind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual;
if (kind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0;
if (kind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0;
return true;
});
// Оператор, недопустимый для выбранной колонки (например, «больше» после смены
// field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда.
const isOperationAllowed = operationOptions.some(o => o.value === form.operation);
React.useEffect(() => {
if (!isOperationAllowed) {
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOperationAllowed]);
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
// а не произвольным текстом, который пользователь мог набрать для другого field_name.
React.useEffect(() => {
if (isManagerField && form.value !== 'true' && form.value !== 'false') {
setForm(f => ({ ...f, value: 'true' }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isManagerField]);
const updateCondition = (index: number, patch: Partial<RouteStepCondition>): void => {
setForm(f => ({
...f,
conditions: f.conditions.map((c, i) => {
if (i !== index) return c;
const next = { ...c, ...patch };
// Оператор, недопустимый для новой колонки (например, «больше» после смены
// field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда.
const kind = fieldKindOf(next.fieldName);
if (!operationOptionsFor(kind).some(o => o.value === next.operation)) next.operation = ComparisonOperator.Equal;
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
// а не произвольным текстом, который пользователь мог набрать для другого field_name.
if (kind === 'bool' && next.value !== 'true' && next.value !== 'false') next.value = 'true';
return next;
})
}));
};
const addCondition = (): void => setForm(f => ({ ...f, conditions: [...f.conditions, EMPTY_CONDITION] }));
const removeCondition = (index: number): void =>
setForm(f => ({ ...f, conditions: f.conditions.filter((_, i) => i !== index) }));
// Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ
// совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо
@@ -336,13 +348,18 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationPosition);
return;
}
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) {
submit.fail(texts.routeSteps.validationCondition);
return;
}
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber);
return;
if (form.hasCondition) {
for (const c of form.conditions) {
if (!c.fieldName.trim() || !c.value.trim()) {
submit.fail(texts.routeSteps.validationCondition);
return;
}
const kind = fieldKindOf(c.fieldName);
if (isNumericFieldKind(kind) && !Number.isFinite(Number(c.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber(c.fieldName));
return;
}
}
}
if (!isValidSlaHours(form.slaHours)) {
submit.fail(texts.routeSteps.validationSlaHours);
@@ -357,9 +374,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
position: form.kind === 'position' ? form.position.trim() : null,
projectRole: form.kind === 'projectRole' ? form.projectRole : null,
headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null,
operation: form.hasCondition ? form.operation : null,
value: form.hasCondition ? form.value.trim() : null,
conditions: form.hasCondition
? form.conditions.map(c => ({ fieldName: c.fieldName.trim(), operation: c.operation, value: c.value.trim() }))
: null,
conditionsLogic: form.conditionsLogic,
slaHours: parseSlaHours(form.slaHours),
askOverdueReason: triStateToBool(form.askOverdueReason)
};
@@ -495,54 +513,75 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
/>
</Field>
{form.hasCondition && (
<>
<Field
label={texts.routeSteps.conditionFieldNameLabel}
required
full
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined}
>
<Select
options={fieldNameOptions}
value={form.fieldName}
disabled={sharePointFields.isLoading}
placeholder={
sharePointFields.isLoading
? texts.routeSteps.conditionFieldNameLoading
: fieldNameOptions.length === 0
? texts.routeSteps.conditionFieldNameEmpty
: texts.routeSteps.conditionFieldNamePlaceholder
}
onChange={e => setForm({ ...form, fieldName: e.target.value })}
/>
</Field>
<Field label={texts.routeSteps.conditionOperationLabel} required>
<Select
options={operationOptions}
value={form.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
/>
</Field>
<Field label={texts.routeSteps.conditionValueLabel} required>
{isManagerField ? (
<Select
options={IS_MANAGER_VALUE_OPTIONS}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
/>
) : (
<TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
)}
</Field>
</>
)}
</FormGrid>
{form.hasCondition && (
<div className={styles.conditions}>
{form.conditions.length > 1 && (
<Field label={texts.routeSteps.conditionLogicLabel}>
<Select
options={CONDITIONS_LOGIC_OPTIONS}
value={form.conditionsLogic}
onChange={e => setForm({ ...form, conditionsLogic: e.target.value as ConditionsLogic })}
/>
</Field>
)}
{form.conditions.map((condition, index) => {
const kind = fieldKindOf(condition.fieldName);
const fieldNameOptions = fieldNameOptionsFor(condition.fieldName);
return (
// eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом
<div className={styles.conditionRow} key={index}>
<Select
options={fieldNameOptions}
value={condition.fieldName}
disabled={sharePointFields.isLoading}
placeholder={
sharePointFields.isLoading
? texts.routeSteps.conditionFieldNameLoading
: fieldNameOptions.length === 0
? texts.routeSteps.conditionFieldNameEmpty
: texts.routeSteps.conditionFieldNamePlaceholder
}
onChange={e => updateCondition(index, { fieldName: e.target.value })}
/>
<Select
options={operationOptionsFor(kind)}
value={condition.operation}
onChange={e => updateCondition(index, { operation: e.target.value as ComparisonOperator })}
/>
{kind === 'bool' ? (
<Select
options={IS_MANAGER_VALUE_OPTIONS}
value={condition.value}
onChange={e => updateCondition(index, { value: e.target.value })}
/>
) : (
<TextInput
type={isNumericFieldKind(kind) ? 'number' : 'text'}
value={condition.value}
onChange={e => updateCondition(index, { value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
)}
<IconButton
icon="trash"
label={texts.routeSteps.conditionRemove}
danger
disabled={form.conditions.length === 1}
onClick={() => removeCondition(index)}
/>
</div>
);
})}
{sharePointFields.error && <div className={styles.conditionsHint}>{texts.routeSteps.conditionFieldNameError(sharePointFields.error)}</div>}
<Button variant="secondary" size="sm" icon="plus" onClick={addCondition}>
{texts.routeSteps.conditionAdd}
</Button>
</div>
)}
</FormModal>
);
};