копирование есть
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри
|
||||
// Field, поэтому отступ сверху небольшой — визуально это продолжение поля,
|
||||
// а не отдельный блок.
|
||||
.positionPreview {
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
background: $gray-50;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// Найдено — зелёная рамка: должность рабочая, шаг резолвится.
|
||||
.previewOk {
|
||||
border-color: $green-600;
|
||||
background: rgba($green-600, .06);
|
||||
}
|
||||
|
||||
// Не найдено — предупреждение: шаг останется без исполнителя.
|
||||
.previewMiss {
|
||||
border-color: $gold-500;
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.previewList {
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.previewPerson {
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.previewCompany {
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// Похожая должность — кнопка: клик подставляет её точное написание в поле,
|
||||
// чтобы не переписывать вручную и не ошибиться в регистре/пробелах.
|
||||
.previewSuggest {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-family: $font-sans;
|
||||
font-size: 12px;
|
||||
color: $navy-700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.previewSuggestCount {
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RouteStepFormModal.module.scss';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
Employee,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from '../../models';
|
||||
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
@@ -186,6 +187,75 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
// 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 <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewLoading}</div>;
|
||||
}
|
||||
// Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это
|
||||
// сказало бы неправду про корректную должность.
|
||||
if (employees.length === 0) {
|
||||
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewNoDictionary}</div>;
|
||||
}
|
||||
|
||||
if (positionMatches.exact.length > 0) {
|
||||
return (
|
||||
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
|
||||
<span className={styles.previewTitle}>
|
||||
{texts.routeSteps.positionPreviewFound(positionMatches.exact.length)}
|
||||
</span>
|
||||
<ul className={styles.previewList}>
|
||||
{positionMatches.exact.map(e => (
|
||||
<li key={e.email}>
|
||||
<span className={styles.previewPerson}>{e.fio}</span>
|
||||
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
|
||||
<span className={styles.previewTitle}>{texts.routeSteps.positionPreviewNone}</span>
|
||||
{positionMatches.similar.length > 0 && (
|
||||
<>
|
||||
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewSimilar}</div>
|
||||
<ul className={styles.previewList}>
|
||||
{positionMatches.similar.map(s => (
|
||||
<li key={s.position}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.previewSuggest}
|
||||
onClick={() => setForm(f => ({ ...f, position: s.position }))}
|
||||
>
|
||||
{s.position} — {s.fio}
|
||||
{s.count > 1 && (
|
||||
<span className={styles.previewSuggestCount}>
|
||||
{' '}
|
||||
({texts.routeSteps.positionPreviewMore(s.count - 1)})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
const submitForm = (): void => {
|
||||
const orderNo = Number(form.orderNo);
|
||||
if (!orderNo || orderNo < 1) {
|
||||
@@ -304,6 +374,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
onChange={e => setForm({ ...form, position: e.target.value })}
|
||||
placeholder={texts.routeSteps.positionPlaceholder}
|
||||
/>
|
||||
{positionPreview}
|
||||
</Field>
|
||||
)}
|
||||
{form.kind === 'projectRole' && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RoutesScreen.module.scss';
|
||||
import { RouteFormModal } from './RouteFormModal';
|
||||
import { RouteCopyModal } from './RouteCopyModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { TemplateRoute } from '../../models';
|
||||
@@ -69,6 +70,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
||||
|
||||
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||
|
||||
@@ -145,6 +147,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
|
||||
<IconButton icon="copy" label={texts.routes.copy} onClick={() => setCopying(r)} />
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
@@ -170,6 +173,19 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{copying && (
|
||||
<RouteCopyModal
|
||||
api={api}
|
||||
source={copying}
|
||||
requestTypeName={requestTypeName(copying.requestTypeId)}
|
||||
companies={portalDictionaries.companies}
|
||||
departments={portalDictionaries.departments}
|
||||
dictionaryLoading={portalDictionaries.isLoading}
|
||||
onCopied={routes.reload}
|
||||
onClose={() => setCopying(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title={texts.routes.deleteTitle}
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface IFormModalProps {
|
||||
/** Текст ошибки: и валидации формы, и ответа бэкенда. */
|
||||
error?: string;
|
||||
isSaving: boolean;
|
||||
/** Подпись кнопки подтверждения — по умолчанию «Сохранить» (см. копирование маршрута). */
|
||||
submitLabel?: string;
|
||||
/** Подпись той же кнопки во время запроса — по умолчанию «Сохранение...». */
|
||||
savingLabel?: string;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
@@ -18,7 +22,16 @@ export interface IFormModalProps {
|
||||
* Модальное окно формы: одинаковая обвязка «ошибка сверху, Отмена/Сохранить снизу»
|
||||
* для всех справочников, чтобы экраны описывали только поля.
|
||||
*/
|
||||
export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, onSubmit, onClose, children }) => (
|
||||
export const FormModal: React.FC<IFormModalProps> = ({
|
||||
title,
|
||||
error,
|
||||
isSaving,
|
||||
submitLabel = texts.common.save,
|
||||
savingLabel = texts.common.saving,
|
||||
onSubmit,
|
||||
onClose,
|
||||
children
|
||||
}) => (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
@@ -26,7 +39,7 @@ export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, o
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
|
||||
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
||||
{isSaving ? texts.common.saving : texts.common.save}
|
||||
{isSaving ? savingLabel : submitLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ export type IconName =
|
||||
| 'briefcase'
|
||||
| 'flag'
|
||||
| 'crown'
|
||||
| 'refresh';
|
||||
| 'refresh'
|
||||
| 'copy';
|
||||
|
||||
let spriteInjected = false;
|
||||
|
||||
@@ -51,6 +52,7 @@ const SPRITE_SVG = `
|
||||
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 3v18"/><path d="M5 4h13l-3 4 3 4H5"/></symbol>
|
||||
<symbol id="i-crown" viewBox="0 0 24 24"><path d="m3 8 4 3 5-6 5 6 4-3-2 11H5z"/></symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></symbol>
|
||||
`;
|
||||
|
||||
function ensureSpriteInjected(): void {
|
||||
|
||||
Reference in New Issue
Block a user