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 = ({ api, route, routeCompanyName, requestType, sharePoint, editing, nextOrderNo, employees, employeesLoading, onSaved, onClose }) => { const [form, setForm] = React.useState(() => 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): boolean => kind === 'numeric' || kind === 'unknown'; // Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше». // Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем. const operationOptionsFor = (kind: ReturnType): 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): 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
{texts.routeSteps.positionPreviewLoading}
; } // Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это // сказало бы неправду про корректную должность. if (employees.length === 0) { return
{texts.routeSteps.positionPreviewNoDictionary}
; } 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 (
{routeCompanyName ? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length) : texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
    {inRouteCompany.map(e => { // Показываем ИМЕННО ту компанию, по которой сотрудник совпал с // маршрутом — она не обязана быть его company (первой из // справочника), если он числится в нескольких (см. companies). const shownCompany = routeCompanyName ?? e.company; return (
  • {e.fio} {shownCompany && · {shownCompany}}
  • ); })}
); } // routeCompanyName задан, но среди найденных нет никого из этой компании — // должность существует, но не там, где её будет искать этот маршрут. return (
{texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)}
{texts.routeSteps.positionPreviewOtherCompanies}
    {positionMatches.exact.map(e => (
  • {e.fio} {e.company && · {e.company}}
  • ))}
); } return (
{texts.routeSteps.positionPreviewNone} {positionMatches.similar.length > 0 && ( <>
{texts.routeSteps.positionPreviewSimilar}
    {positionMatches.similar.map(s => (
  • ))}
)}
); })(); 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 ( setForm({ ...form, orderNo: e.target.value })} /> setForm({ ...form, projectRole: e.target.value as ProjectRole })} /> )} {form.kind === 'headType' && ( setForm({ ...form, askOverdueReason: e.target.value as TriState })} /> setForm({ ...form, hasCondition: v })} /> {form.hasCondition && (
{form.conditions.length > 1 && ( updateCondition(index, { fieldName: e.target.value })} /> updateCondition(index, { value: e.target.value })} /> ) : ( updateCondition(index, { value: e.target.value })} placeholder={texts.routeSteps.conditionValuePlaceholder} /> )} removeCondition(index)} />
); })} {sharePointFields.error &&
{texts.routeSteps.conditionFieldNameError(sharePointFields.error)}
} )}
); };