Обходной лист (#12)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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,
|
||||
fieldName: step.fieldName,
|
||||
operation: step.operation,
|
||||
value: step.value,
|
||||
slaHours: step.slaHours,
|
||||
askOverdueReason: step.askOverdueReason
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Копирование маршрута вместе с шагами в пределах ОДНОГО типа заявки.
|
||||
*
|
||||
* Маршрут уникален по (тип заявки, компания, отдел, «по проекту») — см.
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user