2b8fe77fac
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
230 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
};
|