import * as React from 'react'; import { Company, CreateTemplateRouteStepInput, Department, ProjectRole, TemplateRoute, TemplateRouteStep } from '../../models'; import { texts } from '../../texts'; import { shortGuid } from '../../utils/guid'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useRouteSteps } from '../../hooks/useRouteSteps'; import { ApprovalRouteApi } from '../../services/api'; import { ApiClientError } from '../../services/httpClient'; import { Alert, Checkbox, Field, FormGrid, FormModal, SearchableSelect } from '../ui'; export interface IRouteCopyModalProps { api: ApprovalRouteApi; /** Маршрут-источник: тип заявки у копии тот же, меняются только «координаты». */ source: TemplateRoute; requestTypeName: string; companies: Company[]; departments: Department[]; dictionaryLoading: boolean; onCopied: () => void; onClose: () => void; } interface IFormState { companyId: string; departmentId: string; byProject: boolean; requiresControl: boolean; } /** Тело для создания шага-копии: всё, кроме id/routeId, переносится как есть. */ function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput { return { orderNo: step.orderNo, executorEmail: step.executorEmail, position: step.position, projectRole: step.projectRole, headType: step.headType, status: step.status, allowReassignToOtherApprovers: step.allowReassignToOtherApprovers, conditions: step.conditions, conditionsLogic: step.conditionsLogic, slaHours: step.slaHours, askOverdueReason: step.askOverdueReason }; } /** * Копирование маршрута вместе с шагами в пределах ОДНОГО типа заявки. * * Маршрут уникален по (тип заявки, компания, отдел, «по проекту») — см. * HasDuplicateAsync в TemplateRouteService. Тип заявки у копии тот же, поэтому * копия обязана отличаться хотя бы одним из остальных трёх полей; проверяем это * до запроса, чтобы вместо 409 от бэкенда показать понятную причину. * * Шаги переносятся по одному. Если хоть один не создался, СОЗДАННЫЙ МАРШРУТ * УДАЛЯЕТСЯ: наполовину скопированный маршрут хуже, чем отсутствующий — он бы * молча резолвился с недостающими согласующими. */ export const RouteCopyModal: React.FC = ({ api, source, requestTypeName, companies, departments, dictionaryLoading, onCopied, onClose }) => { const [form, setForm] = React.useState({ companyId: source.companyId ?? '', departmentId: source.departmentId ?? '', byProject: source.byProject, requiresControl: source.requiresControl }); const steps = useRouteSteps(api, source.id); const submit = useAsyncSubmit(); const departmentsForSelectedCompany = React.useMemo( () => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []), [departments, form.companyId] ); const sourceCompany = source.companyId ? companies.find(c => c.id === source.companyId)?.name ?? shortGuid(source.companyId) : texts.routeSteps.anyCompany; const sourceDepartment = source.departmentId ? departments.find(d => d.id === source.departmentId)?.name ?? shortGuid(source.departmentId) : texts.routeSteps.anyDepartment; // Шаги с ролью в проекте бэкенд не примет в маршрут без признака «по проекту». const projectRoleSteps = steps.items.filter(s => s.projectRole && s.projectRole !== ProjectRole.None).length; const projectRoleBlocks = projectRoleSteps > 0 && !form.byProject; // Копия должна отличаться от источника: тип заявки у них общий, значит // различие обязано быть в компании, отделе или признаке «по проекту». const sameAsSource = (form.companyId || '') === (source.companyId ?? '') && (form.departmentId || '') === (source.departmentId ?? '') && form.byProject === source.byProject; const submitForm = (): void => { if (sameAsSource) { submit.fail(texts.routes.validationCopySame); return; } if (projectRoleBlocks) { submit.fail(texts.routes.copyProjectRoleWarning(projectRoleSteps)); return; } const companyId = form.companyId.trim() ? form.companyId.trim() : null; const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null; submit.run(async () => { const created = await api.templateRoutes.create({ requestTypeId: source.requestTypeId, companyId, departmentId, byProject: form.byProject, requiresControl: form.requiresControl }); // Последовательно, а не Promise.all: при ошибке важно знать, на каком шаге // сорвались, а параллельные запросы к тому же дают гонку по orderNo в логах. try { for (const step of [...steps.items].sort((a, b) => a.orderNo - b.orderNo)) { await api.templateRouteSteps.create(created.id, toStepInput(step)).catch((e: ApiClientError) => { throw new Error(texts.routes.copyStepFailed(step.orderNo, e.message)); }); } } catch (stepError) { // Откат: маршрут без части шагов резолвился бы молча и неверно. try { await api.templateRoutes.remove(created.id); } catch { throw new Error( `${(stepError as Error).message} ${texts.routes.copyRollbackFailed(created.id)}` ); } throw new Error(`${(stepError as Error).message} ${texts.routes.copyRolledBack}`); } onCopied(); onClose(); }); }; return ( {texts.routes.copySource(requestTypeName, sourceCompany, sourceDepartment)} {steps.error && {steps.error}} {!steps.error && steps.isLoading && {texts.routes.copyStepsLoading}} {!steps.error && !steps.isLoading && ( 0 ? 'ok' : 'warn'}> {steps.items.length > 0 ? texts.routes.copyStepsCount(steps.items.length) : texts.routes.copyStepsEmpty} )} {projectRoleBlocks && {texts.routes.copyProjectRoleWarning(projectRoleSteps)}} ({ value: c.id, label: c.name }))} value={form.companyId} loading={dictionaryLoading} allowClear clearLabel={texts.routes.companyClear} placeholder={texts.routes.companyPlaceholder} searchPlaceholder={texts.routes.companySearchPlaceholder} onChange={companyId => // Как и в форме маршрута: смена компании обнуляет отдел — прежний // принадлежал другой компании. setForm({ ...form, companyId, departmentId: '' }) } /> ({ value: d.id, label: d.name }))} value={form.departmentId} loading={dictionaryLoading} disabled={!form.companyId} allowClear clearLabel={texts.routes.departmentPlaceholder} placeholder={texts.routes.departmentPlaceholder} searchPlaceholder={texts.routes.departmentSearchPlaceholder} emptyMessage={texts.routes.departmentEmpty} onChange={departmentId => setForm({ ...form, departmentId })} /> setForm({ ...form, byProject: v })} /> setForm({ ...form, requiresControl: v })} /> ); };