import * as React from 'react'; import styles from './RouteStepFormModal.module.scss'; import { ComparisonOperator, Employee, HeadType, ProjectRole, RequestType, RouteStepStatus, StepExecutorKind, TemplateRoute, TemplateRouteStep } from '../../models'; import { comparisonOperatorLabels, 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 { 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; fieldName: string; operation: ComparisonOperator; value: string; /** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */ slaHours: string; askOverdueReason: TriState; } 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] })); /** * Служебное поле условия, не привязанное к списку 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, fieldName: '', operation: ComparisonOperator.GreaterThan, value: '', 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: Boolean(editing.fieldName), fieldName: editing.fieldName ?? '', operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan, value: editing.value ?? '', 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); const fieldNameOptions = React.useMemo(() => { 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 }); } 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 isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true; const operationOptions = isNumericField ? OPERATION_OPTIONS : OPERATION_OPTIONS.filter(o => o.value === ComparisonOperator.Equal); // Строковая колонка допускает только «равно» — если пользователь сменил // field_name на нечисловую колонку, а раньше был выбран «больше»/«меньше», сбрасываем. React.useEffect(() => { if (!isNumericField && form.operation !== ComparisonOperator.Equal) { setForm(f => ({ ...f, operation: ComparisonOperator.Equal })); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNumericField]); // 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]); // Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ // совпадение должности в справочнике (/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 && (!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 (!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, fieldName: form.hasCondition ? form.fieldName.trim() : null, operation: form.hasCondition ? form.operation : null, value: form.hasCondition ? form.value.trim() : null, 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 && ( <> setForm({ ...form, operation: e.target.value as ComparisonOperator })} /> {isManagerField ? (