Несколько условий шага, объединяемых через И/ИЛИ (#16)
<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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,11 @@ export enum HeadType {
|
||||
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
|
||||
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
|
||||
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
|
||||
DepartmentHead = 'DepartmentHead'
|
||||
DepartmentHead = 'DepartmentHead',
|
||||
// Единственное значение, которое резолвится в НЕСКОЛЬКО строк результата — по
|
||||
// директору на каждую компанию инициатора (сотрудник может числиться в
|
||||
// нескольких), все с одним и тем же orderNo (см. TemplateRouteService.ResolveStepAsync).
|
||||
CompanyDirectors = 'CompanyDirectors'
|
||||
}
|
||||
|
||||
export enum ComparisonOperator {
|
||||
@@ -40,3 +44,9 @@ export enum ComparisonOperator {
|
||||
Contains = 'Contains',
|
||||
NotContains = 'NotContains'
|
||||
}
|
||||
|
||||
/** Как объединяются условия шага между собой — не действует, если условие одно или их нет. */
|
||||
export enum ConditionsLogic {
|
||||
And = 'And',
|
||||
Or = 'Or'
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
|
||||
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
|
||||
|
||||
import { RouteStepStatus } from './enums';
|
||||
import { HeadType, RouteStepStatus } from './enums';
|
||||
|
||||
/** Значение поля списка SharePoint (field_name шага) для проверки условий шагов. */
|
||||
export interface ResolveFieldValue {
|
||||
@@ -28,6 +28,12 @@ export interface ResolvedRouteStep {
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
/** Причина, по которой исполнитель шага не определился (текст с бэкенда). */
|
||||
warning: string | null;
|
||||
/**
|
||||
* HeadType шага (Chief/DirectionHead/...), если исполнитель задан типом
|
||||
* руководителя, иначе null. В отличие от position (реальная должность
|
||||
* резолвнутого сотрудника из справочника) — это код роли из самого шага.
|
||||
*/
|
||||
positionCode: HeadType | null;
|
||||
}
|
||||
|
||||
export interface ResolveTemplateRouteResult {
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
// (Application/TemplateRouteSteps/*.cs).
|
||||
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда (C# nullable-типы сериализуются в null, не в отсутствие поля) */
|
||||
|
||||
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
|
||||
import { ComparisonOperator, ConditionsLogic, HeadType, ProjectRole, RouteStepStatus } from './enums';
|
||||
|
||||
/** Одно условие появления шага — см. Application/TemplateRouteSteps/RouteStepConditionDto. */
|
||||
export interface RouteStepCondition {
|
||||
/** Столбец списка SharePoint (internal name), по которому проверяется условие. */
|
||||
fieldName: string;
|
||||
operation: ComparisonOperator;
|
||||
/** Значение для сравнения с fieldName. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TemplateRouteStep {
|
||||
id: number;
|
||||
@@ -14,11 +23,10 @@ export interface TemplateRouteStep {
|
||||
status: RouteStepStatus;
|
||||
routeId: number;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
/** Столбец списка SharePoint (internal name), по которому проверяется условие. */
|
||||
fieldName: string | null;
|
||||
operation: ComparisonOperator | null;
|
||||
/** Значение для сравнения с fieldName. */
|
||||
value: string | null;
|
||||
/** Условия появления шага. Пусто — шаг применяется всегда. */
|
||||
conditions: RouteStepCondition[];
|
||||
/** Как объединяются conditions между собой — не действует, если условий меньше двух. */
|
||||
conditionsLogic: ConditionsLogic;
|
||||
/**
|
||||
* SLA шага в часах — перекрывает SLA типа заявки.
|
||||
* null — «не задано, наследовать»; 0 — «напоминать сразу».
|
||||
@@ -38,9 +46,8 @@ export interface CreateTemplateRouteStepInput {
|
||||
headType: HeadType | null;
|
||||
status: RouteStepStatus;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
fieldName: string | null;
|
||||
operation: ComparisonOperator | null;
|
||||
value: string | null;
|
||||
conditions: RouteStepCondition[] | null;
|
||||
conditionsLogic: ConditionsLogic;
|
||||
slaHours: number | null;
|
||||
askOverdueReason: boolean | null;
|
||||
}
|
||||
@@ -55,9 +62,8 @@ export interface PatchTemplateRouteStepInput {
|
||||
headType?: HeadType | null;
|
||||
status?: RouteStepStatus;
|
||||
allowReassignToOtherApprovers?: boolean;
|
||||
fieldName?: string | null;
|
||||
operation?: ComparisonOperator | null;
|
||||
value?: string | null;
|
||||
conditions?: RouteStepCondition[] | null;
|
||||
conditionsLogic?: ConditionsLogic;
|
||||
slaHours?: number | null;
|
||||
askOverdueReason?: boolean | null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
|
||||
|
||||
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './models';
|
||||
import { ComparisonOperator, ConditionsLogic, HeadType, ProjectRole, RouteStepStatus } from './models';
|
||||
import { pluralRu } from './utils/plural';
|
||||
|
||||
// ---- Подписи enum'ов ----
|
||||
@@ -32,7 +32,8 @@ export const headTypeLabels: Record<HeadType, string> = {
|
||||
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||
[HeadType.DirectorTansu]: 'Директор Tansu',
|
||||
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
|
||||
[HeadType.DepartmentHead]: 'Руководитель указанного отдела'
|
||||
[HeadType.DepartmentHead]: 'Руководитель указанного отдела',
|
||||
[HeadType.CompanyDirectors]: 'Директора компаний инициатора'
|
||||
};
|
||||
|
||||
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
|
||||
@@ -55,6 +56,17 @@ export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = {
|
||||
[ComparisonOperator.NotContains]: 'не содержит'
|
||||
};
|
||||
|
||||
export const conditionsLogicLabels: Record<ConditionsLogic, string> = {
|
||||
[ConditionsLogic.And]: 'И (выполняются все условия)',
|
||||
[ConditionsLogic.Or]: 'ИЛИ (достаточно одного условия)'
|
||||
};
|
||||
|
||||
/** Как условия шага склеиваются в одну строку в колонке «Условие» таблицы шагов. */
|
||||
export const conditionsLogicJoiners: Record<ConditionsLogic, string> = {
|
||||
[ConditionsLogic.And]: ' И ',
|
||||
[ConditionsLogic.Or]: ' ИЛИ '
|
||||
};
|
||||
|
||||
export const texts = {
|
||||
common: {
|
||||
home: 'Главная',
|
||||
@@ -330,11 +342,15 @@ export const texts = {
|
||||
conditionOperationLabel: 'Оператор сравнения',
|
||||
conditionValueLabel: 'Значение',
|
||||
conditionValuePlaceholder: 'Например, 1000',
|
||||
conditionLogicLabel: 'Логика объединения условий',
|
||||
conditionAdd: 'Добавить условие',
|
||||
conditionRemove: 'Удалить условие',
|
||||
validationOrderNo: 'Порядковый номер шага обязателен.',
|
||||
validationExecutor: 'Выберите сотрудника-исполнителя.',
|
||||
validationPosition: 'Укажите должность.',
|
||||
validationCondition: 'Заполните поле списка, оператор и значение условия.',
|
||||
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
|
||||
validationCondition: 'В каждом условии заполните поле списка, оператор и значение.',
|
||||
validationConditionNumber: (fieldName: string): string =>
|
||||
`Поле «${fieldName}» числовое — значение условия должно быть числом.`,
|
||||
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
|
||||
deleteTitle: 'Удалить шаг?',
|
||||
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComparisonOperator, Employee, HeadType, ProjectRole, RouteStepStatus, TemplateRouteStep } from '../models';
|
||||
import { ComparisonOperator, ConditionsLogic, Employee, HeadType, ProjectRole, RouteStepStatus, TemplateRouteStep } from '../models';
|
||||
import { describeStepCondition, describeStepExecutor, detectExecutorKind } from './stepExecutor';
|
||||
|
||||
function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
|
||||
@@ -12,9 +12,8 @@ function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
|
||||
status: RouteStepStatus.Approval,
|
||||
routeId: 1,
|
||||
allowReassignToOtherApprovers: false,
|
||||
fieldName: null,
|
||||
operation: null,
|
||||
value: null,
|
||||
conditions: [],
|
||||
conditionsLogic: ConditionsLogic.And,
|
||||
slaHours: null,
|
||||
askOverdueReason: null,
|
||||
...overrides
|
||||
@@ -95,16 +94,40 @@ describe('describeStepExecutor', () => {
|
||||
});
|
||||
|
||||
describe('describeStepCondition', () => {
|
||||
it('undefined, если условие не задано целиком', () => {
|
||||
it('undefined, если условий нет', () => {
|
||||
expect(describeStepCondition(step({}))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('undefined, если operation === None', () => {
|
||||
expect(describeStepCondition(step({ fieldName: 'Amount', operation: ComparisonOperator.None, value: '100' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('собирает "поле оператор значение", если условие задано полностью', () => {
|
||||
const result = describeStepCondition(step({ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '100' }));
|
||||
it('собирает "поле оператор значение", если условие одно', () => {
|
||||
const result = describeStepCondition(
|
||||
step({ conditions: [{ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '100' }] })
|
||||
);
|
||||
expect(result).toBe('Amount > 100');
|
||||
});
|
||||
|
||||
it('несколько условий склеиваются через "И"', () => {
|
||||
const result = describeStepCondition(
|
||||
step({
|
||||
conditions: [
|
||||
{ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '1000' },
|
||||
{ fieldName: 'Priority', operation: ComparisonOperator.Equal, value: 'Urgent' }
|
||||
],
|
||||
conditionsLogic: ConditionsLogic.And
|
||||
})
|
||||
);
|
||||
expect(result).toBe('Amount > 1000 И Priority = Urgent');
|
||||
});
|
||||
|
||||
it('несколько условий склеиваются через "ИЛИ"', () => {
|
||||
const result = describeStepCondition(
|
||||
step({
|
||||
conditions: [
|
||||
{ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '1000' },
|
||||
{ fieldName: 'Priority', operation: ComparisonOperator.Equal, value: 'Urgent' }
|
||||
],
|
||||
conditionsLogic: ConditionsLogic.Or
|
||||
})
|
||||
);
|
||||
expect(result).toBe('Amount > 1000 ИЛИ Priority = Urgent');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ComparisonOperator, Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
|
||||
import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } from '../texts';
|
||||
import { ConditionsLogic, Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
|
||||
import { comparisonOperatorSymbols, conditionsLogicJoiners, headTypeLabels, projectRoleLabels, texts } from '../texts';
|
||||
import { findEmployeeByEmail } from './employee';
|
||||
|
||||
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
|
||||
@@ -42,8 +42,11 @@ export function describeStepExecutor(step: TemplateRouteStep, employees: Employe
|
||||
return { primary: texts.common.empty };
|
||||
}
|
||||
|
||||
/** Колонка «Условие» в таблице шагов — undefined, если у шага условия нет. */
|
||||
/** Колонка «Условие» в таблице шагов — undefined, если у шага условий нет. */
|
||||
export function describeStepCondition(step: TemplateRouteStep): string | undefined {
|
||||
if (!step.fieldName || !step.operation || step.operation === ComparisonOperator.None || !step.value) return undefined;
|
||||
return `${step.fieldName} ${comparisonOperatorSymbols[step.operation]} ${step.value}`;
|
||||
if (step.conditions.length === 0) return undefined;
|
||||
const joiner = conditionsLogicJoiners[step.conditionsLogic ?? ConditionsLogic.And];
|
||||
return step.conditions
|
||||
.map(c => `${c.fieldName} ${comparisonOperatorSymbols[c.operation]} ${c.value}`)
|
||||
.join(joiner);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user