Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/RouteCopyModal.tsx
T
gorbenko.pavel 2b8fe77fac
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 3m39s
Auto approve (#21)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
2026-09-14 09:01:56 +00:00

230 lines
9.2 KiB
TypeScript

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,
autoApprove: step.autoApprove
};
}
/**
* Копирование маршрута вместе с шагами в пределах ОДНОГО типа заявки.
*
* Маршрут уникален по (тип заявки, компания, отдел, «по проекту») — см.
* HasDuplicateAsync в TemplateRouteService. Тип заявки у копии тот же, поэтому
* копия обязана отличаться хотя бы одним из остальных трёх полей; проверяем это
* до запроса, чтобы вместо 409 от бэкенда показать понятную причину.
*
* Шаги переносятся по одному. Если хоть один не создался, СОЗДАННЫЙ МАРШРУТ
* УДАЛЯЕТСЯ: наполовину скопированный маршрут хуже, чем отсутствующий — он бы
* молча резолвился с недостающими согласующими.
*/
export const RouteCopyModal: React.FC<IRouteCopyModalProps> = ({
api,
source,
requestTypeName,
companies,
departments,
dictionaryLoading,
onCopied,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>({
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 (
<FormModal
title={texts.routes.copyTitle(source.id)}
error={submit.error}
isSaving={submit.isBusy}
submitLabel={texts.routes.copyAction}
savingLabel={texts.routes.copyInProgress}
onSubmit={submitForm}
onClose={onClose}
>
<Alert kind="ok">{texts.routes.copySource(requestTypeName, sourceCompany, sourceDepartment)}</Alert>
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{!steps.error && steps.isLoading && <Alert kind="ok">{texts.routes.copyStepsLoading}</Alert>}
{!steps.error && !steps.isLoading && (
<Alert kind={steps.items.length > 0 ? 'ok' : 'warn'}>
{steps.items.length > 0 ? texts.routes.copyStepsCount(steps.items.length) : texts.routes.copyStepsEmpty}
</Alert>
)}
{projectRoleBlocks && <Alert kind="warn">{texts.routes.copyProjectRoleWarning(projectRoleSteps)}</Alert>}
<FormGrid>
<Field label={texts.routes.companyLabel} full hint={texts.routes.copyHint}>
<SearchableSelect
options={companies.map(c => ({ 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: '' })
}
/>
</Field>
<Field
label={texts.routes.departmentLabel}
full
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
>
<SearchableSelect
options={departmentsForSelectedCompany.map(d => ({ 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 })}
/>
</Field>
<Field label={texts.routes.byProjectLabel}>
<Checkbox
label={texts.routes.byProjectCheckbox}
checked={form.byProject}
onChange={v => setForm({ ...form, byProject: v })}
/>
</Field>
<Field label={texts.routes.controlLabel}>
<Checkbox
label={texts.routes.controlCheckbox}
checked={form.requiresControl}
onChange={v => setForm({ ...form, requiresControl: v })}
/>
</Field>
</FormGrid>
</FormModal>
);
};