ba48ed1a0e
<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
588 lines
26 KiB
TypeScript
588 lines
26 KiB
TypeScript
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,
|
||
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 { Button, Checkbox, Field, FormGrid, FormModal, IconButton, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
|
||
import {
|
||
boolToTriState,
|
||
isValidSlaHours,
|
||
parseSlaHours,
|
||
slaHoursToInput,
|
||
triStateToBool,
|
||
TriState,
|
||
TRI_STATE_OPTIONS
|
||
} from '../../utils/sla';
|
||
|
||
export interface IRouteStepFormModalProps {
|
||
api: ApprovalRouteApi;
|
||
route: TemplateRoute;
|
||
/**
|
||
* Название компании маршрута, если она задана (route.companyId) — undefined
|
||
* для запасного маршрута («любая компания»). Резолв шага «по должности»
|
||
* ищет исполнителя ИМЕННО в этой компании (initiatorCompany = компания
|
||
* инициатора, а маршрут с заданной компанией применяется только к таким
|
||
* инициаторам), поэтому подсказка должности должна сужаться под неё же —
|
||
* иначе показала бы человека, которого этот маршрут никогда не найдёт.
|
||
*/
|
||
routeCompanyName?: string;
|
||
/** Undefined, пока грузится тип заявки маршрута. */
|
||
requestType?: RequestType;
|
||
sharePoint: SharePointListsClient;
|
||
/** Undefined — создание нового шага, иначе редактирование существующего. */
|
||
editing?: TemplateRouteStep;
|
||
nextOrderNo: number;
|
||
employees: Employee[];
|
||
employeesLoading: boolean;
|
||
onSaved: (savedStep: TemplateRouteStep) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
interface IFormState {
|
||
orderNo: string;
|
||
status: RouteStepStatus;
|
||
kind: StepExecutorKind;
|
||
executorEmail: string;
|
||
position: string;
|
||
projectRole: ProjectRole;
|
||
headType: HeadType;
|
||
allowReassignToOtherApprovers: boolean;
|
||
hasCondition: boolean;
|
||
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] }));
|
||
|
||
const PROJECT_ROLE_OPTIONS = Object.values(ProjectRole)
|
||
.filter(v => v !== ProjectRole.None)
|
||
.map(v => ({ value: v, label: projectRoleLabels[v] }));
|
||
|
||
const HEAD_TYPE_OPTIONS = Object.values(HeadType)
|
||
.filter(v => v !== HeadType.None)
|
||
.map(v => ({ value: v, label: headTypeLabels[v] }));
|
||
|
||
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];
|
||
|
||
/** «Содержит»/«Не содержит» — поиск подстроки, только для текстовых колонок. */
|
||
const TEXT_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.Contains, ComparisonOperator.NotContains];
|
||
|
||
/**
|
||
* Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде
|
||
* (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ
|
||
* («Должность» — например, у увольняемого в обходном листе), а не по должности того,
|
||
* кто отправил заявку. Поэтому поле доступно всегда, даже если у типа заявки не
|
||
* настроен list_name — источник данных не сам список, а fieldValues resolve-запроса.
|
||
*/
|
||
const IS_MANAGER_FIELD = 'IsManager';
|
||
const SYNTHETIC_FIELD_OPTIONS = [{ value: IS_MANAGER_FIELD, label: texts.routeSteps.conditionFieldIsManagerLabel }];
|
||
const IS_MANAGER_VALUE_OPTIONS = [
|
||
{ value: 'true', label: texts.common.yes },
|
||
{ value: 'false', label: texts.common.no }
|
||
];
|
||
|
||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||
if (!editing) {
|
||
return {
|
||
orderNo: String(nextOrderNo),
|
||
status: RouteStepStatus.Approval,
|
||
kind: 'email',
|
||
executorEmail: '',
|
||
position: '',
|
||
projectRole: ProjectRole.ProjectManager,
|
||
headType: HeadType.Chief,
|
||
allowReassignToOtherApprovers: false,
|
||
hasCondition: false,
|
||
conditions: [EMPTY_CONDITION],
|
||
conditionsLogic: ConditionsLogic.And,
|
||
slaHours: '',
|
||
askOverdueReason: ''
|
||
};
|
||
}
|
||
return {
|
||
orderNo: String(editing.orderNo),
|
||
status: editing.status,
|
||
kind: detectExecutorKind(editing),
|
||
executorEmail: editing.executorEmail ?? '',
|
||
position: editing.position ?? '',
|
||
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: editing.conditions.length > 0,
|
||
conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION],
|
||
conditionsLogic: editing.conditionsLogic,
|
||
slaHours: slaHoursToInput(editing.slaHours),
|
||
askOverdueReason: boolToTriState(editing.askOverdueReason)
|
||
};
|
||
}
|
||
|
||
/** Форма создания/редактирования шага маршрута — вынесена из RouteStepsScreen. */
|
||
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||
api,
|
||
route,
|
||
routeCompanyName,
|
||
requestType,
|
||
sharePoint,
|
||
editing,
|
||
nextOrderNo,
|
||
employees,
|
||
employeesLoading,
|
||
onSaved,
|
||
onClose
|
||
}) => {
|
||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||
const submit = useAsyncSubmit();
|
||
const hasListId = Boolean(requestType?.listId);
|
||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||
|
||
// 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 (currentValue && !options.some(o => o.value === currentValue)) {
|
||
options.push({ value: currentValue, label: currentValue });
|
||
}
|
||
return options;
|
||
};
|
||
|
||
// Тип столбца известен, только если он есть среди подгруженных полей списка —
|
||
// для значения, введённого до появления поля в списке (или после его удаления),
|
||
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
|
||
// IsManager — служебное булево поле, а не колонка списка, поэтому для него
|
||
// числовые ограничения не действуют в любом случае.
|
||
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 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;
|
||
});
|
||
|
||
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), и опечатка тихо
|
||
// оставляет шаг без исполнителя — до самого резолва об этом узнать негде.
|
||
// Считаем по уже загруженному справочнику сотрудников, без запросов на ввод.
|
||
const positionMatches = React.useMemo(
|
||
() => matchEmployeesByPosition(employees, form.position),
|
||
[employees, form.position]
|
||
);
|
||
|
||
const positionPreview = ((): React.ReactNode => {
|
||
if (!form.position.trim()) return undefined;
|
||
if (employeesLoading) {
|
||
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewLoading}</div>;
|
||
}
|
||
// Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это
|
||
// сказало бы неправду про корректную должность.
|
||
if (employees.length === 0) {
|
||
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewNoDictionary}</div>;
|
||
}
|
||
|
||
if (positionMatches.exact.length > 0) {
|
||
// У маршрута задана конкретная компания — резолв ищет исполнителя ИМЕННО
|
||
// в ней (route.companyId сужает круг инициаторов до этой компании, а
|
||
// бэкенд ищет по компании инициатора). Найденные в других компаниях
|
||
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
|
||
const inRouteCompany = routeCompanyName
|
||
? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1)
|
||
: positionMatches.exact;
|
||
|
||
if (inRouteCompany.length > 0) {
|
||
return (
|
||
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
|
||
<span className={styles.previewTitle}>
|
||
{routeCompanyName
|
||
? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length)
|
||
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
|
||
</span>
|
||
<ul className={styles.previewList}>
|
||
{inRouteCompany.map(e => {
|
||
// Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
|
||
// маршрутом — она не обязана быть его company (первой из
|
||
// справочника), если он числится в нескольких (см. companies).
|
||
const shownCompany = routeCompanyName ?? e.company;
|
||
return (
|
||
<li key={e.email}>
|
||
<span className={styles.previewPerson}>{e.fio}</span>
|
||
{shownCompany && <span className={styles.previewCompany}> · {shownCompany}</span>}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// routeCompanyName задан, но среди найденных нет никого из этой компании —
|
||
// должность существует, но не там, где её будет искать этот маршрут.
|
||
return (
|
||
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
|
||
<span className={styles.previewTitle}>
|
||
{texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)}
|
||
</span>
|
||
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewOtherCompanies}</div>
|
||
<ul className={styles.previewList}>
|
||
{positionMatches.exact.map(e => (
|
||
<li key={e.email}>
|
||
<span className={styles.previewPerson}>{e.fio}</span>
|
||
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
|
||
<span className={styles.previewTitle}>{texts.routeSteps.positionPreviewNone}</span>
|
||
{positionMatches.similar.length > 0 && (
|
||
<>
|
||
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewSimilar}</div>
|
||
<ul className={styles.previewList}>
|
||
{positionMatches.similar.map(s => (
|
||
<li key={s.position}>
|
||
<button
|
||
type="button"
|
||
className={styles.previewSuggest}
|
||
onClick={() => setForm(f => ({ ...f, position: s.position }))}
|
||
>
|
||
{s.position} — {s.fio}
|
||
{s.count > 1 && (
|
||
<span className={styles.previewSuggestCount}>
|
||
{' '}
|
||
({texts.routeSteps.positionPreviewMore(s.count - 1)})
|
||
</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
})();
|
||
|
||
const submitForm = (): void => {
|
||
const orderNo = Number(form.orderNo);
|
||
if (!orderNo || orderNo < 1) {
|
||
submit.fail(texts.routeSteps.validationOrderNo);
|
||
return;
|
||
}
|
||
if (form.kind === 'email' && !form.executorEmail.trim()) {
|
||
submit.fail(texts.routeSteps.validationExecutor);
|
||
return;
|
||
}
|
||
if (form.kind === 'position' && !form.position.trim()) {
|
||
submit.fail(texts.routeSteps.validationPosition);
|
||
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);
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
orderNo,
|
||
status: form.status,
|
||
allowReassignToOtherApprovers: form.allowReassignToOtherApprovers,
|
||
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
|
||
position: form.kind === 'position' ? form.position.trim() : null,
|
||
projectRole: form.kind === 'projectRole' ? form.projectRole : null,
|
||
headType: form.kind === 'headType' ? form.headType : 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)
|
||
};
|
||
|
||
const request = editing
|
||
? api.templateRouteSteps.update(route.id, editing.id, payload)
|
||
: api.templateRouteSteps.create(route.id, payload);
|
||
|
||
submit.run(() =>
|
||
request.then(step => {
|
||
onSaved(step);
|
||
onClose();
|
||
})
|
||
);
|
||
};
|
||
|
||
return (
|
||
<FormModal
|
||
title={editing ? texts.routeSteps.editTitle(editing.orderNo) : texts.routeSteps.createTitle}
|
||
error={submit.error}
|
||
isSaving={submit.isBusy}
|
||
onSubmit={submitForm}
|
||
onClose={onClose}
|
||
>
|
||
<FormGrid>
|
||
<Field label={texts.routeSteps.orderNoLabel} required>
|
||
<TextInput
|
||
type="number"
|
||
min={1}
|
||
value={form.orderNo}
|
||
onChange={e => setForm({ ...form, orderNo: e.target.value })}
|
||
/>
|
||
</Field>
|
||
<Field label={texts.routeSteps.statusLabel} required>
|
||
<Select
|
||
options={STATUS_OPTIONS}
|
||
value={form.status}
|
||
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label={texts.routeSteps.kindLabel} required full hint={texts.routeSteps.kindHint}>
|
||
<RadioGroup
|
||
name="executorKind"
|
||
value={form.kind}
|
||
onChange={kind => setForm({ ...form, kind })}
|
||
options={[
|
||
{ value: 'email', label: texts.routeSteps.kindEmail },
|
||
{ value: 'position', label: texts.routeSteps.kindPosition },
|
||
{
|
||
value: 'projectRole',
|
||
label: texts.routeSteps.kindProjectRole,
|
||
disabled: !route.byProject,
|
||
disabledHint: texts.routeSteps.kindProjectRoleDisabled
|
||
},
|
||
{ value: 'headType', label: texts.routeSteps.kindHeadType }
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
{form.kind === 'email' && (
|
||
<Field label={texts.routeSteps.executorLabel} required full>
|
||
<SearchableSelect
|
||
options={employees.map(toEmployeeOption)}
|
||
value={form.executorEmail}
|
||
loading={employeesLoading}
|
||
placeholder={texts.routeSteps.executorPlaceholder}
|
||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||
emptyMessage={texts.select.employeesEmpty}
|
||
onChange={executorEmail => setForm({ ...form, executorEmail })}
|
||
/>
|
||
</Field>
|
||
)}
|
||
{form.kind === 'position' && (
|
||
<Field label={texts.routeSteps.positionLabel} required full hint={texts.routeSteps.positionHint}>
|
||
<TextInput
|
||
value={form.position}
|
||
onChange={e => setForm({ ...form, position: e.target.value })}
|
||
placeholder={texts.routeSteps.positionPlaceholder}
|
||
/>
|
||
{positionPreview}
|
||
</Field>
|
||
)}
|
||
{form.kind === 'projectRole' && (
|
||
<Field label={texts.routeSteps.projectRoleLabel} required full>
|
||
<Select
|
||
options={PROJECT_ROLE_OPTIONS}
|
||
value={form.projectRole}
|
||
onChange={e => setForm({ ...form, projectRole: e.target.value as ProjectRole })}
|
||
/>
|
||
</Field>
|
||
)}
|
||
{form.kind === 'headType' && (
|
||
<Field label={texts.routeSteps.headTypeLabel} required full>
|
||
<Select
|
||
options={HEAD_TYPE_OPTIONS}
|
||
value={form.headType}
|
||
onChange={e => setForm({ ...form, headType: e.target.value as HeadType })}
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
<Field label={texts.routeSteps.reassignLabel} full>
|
||
<Checkbox
|
||
label={texts.routeSteps.reassignCheckbox}
|
||
checked={form.allowReassignToOtherApprovers}
|
||
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label={texts.routeSteps.slaHoursLabel} hint={texts.routeSteps.slaHoursHint}>
|
||
<TextInput
|
||
type="number"
|
||
min={0}
|
||
value={form.slaHours}
|
||
onChange={e => setForm({ ...form, slaHours: e.target.value })}
|
||
placeholder={texts.routeSteps.slaHoursPlaceholder}
|
||
/>
|
||
</Field>
|
||
<Field label={texts.routeSteps.askOverdueReasonLabel} hint={texts.routeSteps.askOverdueReasonHint}>
|
||
<Select
|
||
options={TRI_STATE_OPTIONS}
|
||
value={form.askOverdueReason}
|
||
onChange={e => setForm({ ...form, askOverdueReason: e.target.value as TriState })}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
|
||
<Checkbox
|
||
label={texts.routeSteps.conditionCheckbox}
|
||
checked={form.hasCondition}
|
||
onChange={v => setForm({ ...form, hasCondition: v })}
|
||
/>
|
||
</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>
|
||
);
|
||
};
|