Обходной лист (#12)
Tests / backend-test (push) Successful in 1m38s
Tests / frontend-test (push) Successful in 47s

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:
2026-08-17 05:43:52 +00:00
parent d076f4e98d
commit 48ede1420d
37 changed files with 1351 additions and 435 deletions
@@ -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';
@@ -31,6 +32,15 @@ import {
export interface IRouteStepFormModalProps {
api: ApprovalRouteApi;
route: TemplateRoute;
/**
* Название компании маршрута, если она задана (route.companyId) — undefined
* для запасного маршрута («любая компания»). Резолв шага «по должности»
* ищет исполнителя ИМЕННО в этой компании (initiatorCompany = компания
* инициатора, а маршрут с заданной компанией применяется только к таким
* инициаторам), поэтому подсказка должности должна сужаться под неё же —
* иначе показала бы человека, которого этот маршрут никогда не найдёт.
*/
routeCompanyName?: string;
/** Undefined, пока грузится тип заявки маршрута. */
requestType?: RequestType;
sharePoint: SharePointListsClient;
@@ -76,6 +86,20 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
/**
* Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде
* (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ
* («Должность» — например, у увольняемого в обходном листе), а не по должности того,
* кто отправил заявку. Поэтому поле доступно всегда, даже если у типа заявки не
* настроен list_name — источник данных не сам список, а fieldValues resolve-запроса.
*/
const IS_MANAGER_FIELD = 'IsManager';
const SYNTHETIC_FIELD_OPTIONS = [{ value: IS_MANAGER_FIELD, label: texts.routeSteps.conditionFieldIsManagerLabel }];
const IS_MANAGER_VALUE_OPTIONS = [
{ value: 'true', label: texts.common.yes },
{ value: 'false', label: texts.common.no }
];
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) {
return {
@@ -117,6 +141,7 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
api,
route,
routeCompanyName,
requestType,
sharePoint,
editing,
@@ -132,18 +157,23 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
const fieldNameOptions = React.useMemo(() => {
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions];
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
options.unshift({ value: form.fieldName, label: form.fieldName });
options.push({ value: form.fieldName, label: form.fieldName });
}
return options;
}, [sharePointFields.items, form.fieldName]);
const isManagerField = form.fieldName === IS_MANAGER_FIELD;
// Тип столбца известен, только если он есть среди подгруженных полей списка —
// для значения, введённого до появления поля в списке (или после его удаления),
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
// IsManager — служебное булево поле, а не колонка списка, поэтому для него
// числовые ограничения не действуют в любом случае.
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
const isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
const operationOptions = isNumericField
? OPERATION_OPTIONS
@@ -158,6 +188,121 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNumericField]);
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
// а не произвольным текстом, который пользователь мог набрать для другого field_name.
React.useEffect(() => {
if (isManagerField && form.value !== 'true' && form.value !== 'false') {
setForm(f => ({ ...f, value: 'true' }));
}
// 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) {
// У маршрута задана конкретная компания — резолв ищет исполнителя ИМЕННО
// в ней (route.companyId сужает круг инициаторов до этой компании, а
// бэкенд ищет по компании инициатора). Найденные в других компаниях
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
const inRouteCompany = routeCompanyName
? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1)
: positionMatches.exact;
if (inRouteCompany.length > 0) {
return (
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
<span className={styles.previewTitle}>
{routeCompanyName
? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length)
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
</span>
<ul className={styles.previewList}>
{inRouteCompany.map(e => {
// Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
// маршрутом — она не обязана быть его company (первой из
// справочника), если он числится в нескольких (см. companies).
const shownCompany = routeCompanyName ?? e.company;
return (
<li key={e.email}>
<span className={styles.previewPerson}>{e.fio}</span>
{shownCompany && <span className={styles.previewCompany}> · {shownCompany}</span>}
</li>
);
})}
</ul>
</div>
);
}
// routeCompanyName задан, но среди найденных нет никого из этой компании —
// должность существует, но не там, где её будет искать этот маршрут.
return (
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
<span className={styles.previewTitle}>
{texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)}
</span>
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewOtherCompanies}</div>
<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) {
@@ -276,6 +421,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onChange={e => setForm({ ...form, position: e.target.value })}
placeholder={texts.routeSteps.positionPlaceholder}
/>
{positionPreview}
</Field>
)}
{form.kind === 'projectRole' && (
@@ -326,12 +472,11 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
<Checkbox
label={texts.routeSteps.conditionCheckbox}
checked={form.hasCondition}
disabled={!hasListId}
onChange={v => setForm({ ...form, hasCondition: v })}
/>
</Field>
{form.hasCondition && hasListId && (
{form.hasCondition && (
<>
<Field
label={texts.routeSteps.conditionFieldNameLabel}
@@ -361,12 +506,20 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
/>
</Field>
<Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
{isManagerField ? (
<Select
options={IS_MANAGER_VALUE_OPTIONS}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
/>
) : (
<TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
)}
</Field>
</>
)}
@@ -0,0 +1,83 @@
@import '../../../../styles/design-tokens';
// Таблица шагов маршрута собрана вручную (а не через DataTable) из-за rowSpan:
// колонка «№» и стрелки перестановки объединяются на весь блок параллельных
// шагов. Стили повторяют ManageRequestsScreen.module.scss — там та же механика
// для строк заявки (см. ManageRequestsPhaseTable).
.stepCol {
width: 56px;
white-space: nowrap;
text-align: center !important;
}
// Колонка «№» закреплена слева при горизонтальном скролле — на узких экранах/
// в редакторе SharePoint таблица шире контейнера, и без этого номер шага
// (единственный ориентир при длинном горизонтальном скролле) уезжал за край
// вместе с остальными колонками. Зеркало правого .stickyCell в
// DataTable.module.scss (там — action-колонка у правого края).
.stepColSticky {
position: sticky;
left: 0;
background: #fff;
box-shadow: 6px 0 6px -6px rgba(15, 36, 64, .12);
}
.table thead th.stepColSticky {
background: $gray-50;
z-index: 2;
}
.table tbody td.stepColSticky {
z-index: 1;
}
// Явно, а не полагаясь на специфичность: .rowHover td и .stepColSticky иначе
// шли бы вровень, и было бы неочевидно, какой фон победит при следующей правке.
.rowHover td.stepColSticky {
background: $gold-50;
}
// Шаг, идущий одновременно с соседним (общий orderNo) — помечаем знаком ∥,
// иначе два одинаковых номера подряд выглядят как ошибка данных.
.parallelMark {
color: $gold-600;
font-weight: 600;
}
.flipY {
svg { transform: scaleY(-1); }
}
// Штатную подсветку строки отключаем: ячейки с rowSpan принадлежат <tr> только
// первой строки блока, поэтому CSS :hover оставлял «вырез» в подсветке на
// границе объединённой ячейки. Подсвечиваем блок целиком через React-состояние.
.blockAwareWrap :global(tbody tr:hover) {
background: transparent;
}
.rowHover td {
background: $gold-50;
}
// IconButton по умолчанию прозрачная (рамка только по hover). Здесь кнопок
// много и часть неочевидна по смыслу (параллельность, перестановка) — рамка
// нужна всегда, иначе выглядит как текст. !important: это разные CSS-модули,
// порядок их правил в бандле не гарантирован.
.iconBtnVisible {
border-color: $gray-300 !important;
background: #fff !important;
}
// «Объединить с предыдущим» — переключатель состояния, а не разовое действие,
// поэтому включённое состояние залито.
.iconBtnActive {
border-color: $gold-500 !important;
background: $gold-500 !important;
color: $navy-950 !important;
&:hover:not(:disabled) {
border-color: $gold-600 !important;
background: $gold-600 !important;
}
}
@@ -1,14 +1,20 @@
import * as React from 'react';
import tableStyles from '../ui/DataTable.module.scss';
import styles from './RouteStepsScreen.module.scss';
import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
import { reportError } from '../../utils/errors';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import {
ActionsCell,
Alert,
@@ -17,7 +23,6 @@ import {
Button,
CellNote,
ConfirmDialog,
DataTable,
IconButton,
PageHeader,
PrimaryCell
@@ -34,10 +39,17 @@ export interface IRouteStepsScreenProps {
onBack: () => void;
}
/** Шаг + признак «идёт одновременно с предыдущим» (выводится из равного orderNo). */
type Row = TemplateRouteStep & { order: number; parallelWithPrev?: boolean };
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
const steps = useRouteSteps(api, route.id);
const employees = usePortalEmployees(dictionary);
const portalDictionaries = usePortalDictionaries(dictionary);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const [reorderError, setReorderError] = React.useState<string | undefined>();
const [reordering, setReordering] = React.useState(false);
const [hoveredBlock, setHoveredBlock] = React.useState<number | undefined>(undefined);
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
@@ -48,6 +60,15 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
steps.reload
);
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
// показываем усечённый Guid — как в RoutesScreen.
const companyName = route.companyId
? portalDictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId)
: texts.routeSteps.anyCompany;
const departmentName = route.departmentId
? portalDictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId)
: texts.routeSteps.anyDepartment;
const openCreate = (): void => {
setEditing(undefined);
setShowForm(true);
@@ -63,6 +84,56 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
steps.reload();
};
// Параллельность у шагов шаблона кодируется тем же способом, что и у строк
// заявки: равный порядковый номер у соседей (отдельного флага в БД нет —
// см. utils/orderBlocks.ts и TemplateRouteStep.orderNo). markParallel/blockRanges
// работают по полю order, поэтому orderNo продублирован в него.
const withParallel = React.useMemo(
() => markParallel(steps.items.map(s => ({ ...s, order: s.orderNo }))) as Row[],
[steps.items]
);
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
// Перестановка = пересчёт orderNo у затронутых шагов. Патчим только те, у кого
// номер реально изменился: PATCH с одним orderNo сохраняет остальные поля
// (см. PatchAsync на бэкенде — там `r.X ?? entity.X`).
const applyReorder = (mutate: (rows: Row[]) => Row[]): void => {
const renumbered = withOrdersFrom(mutate(withParallel), 0) as Row[];
const updates = renumbered.filter(r => r.orderNo !== r.order).map(r => ({ id: r.id, orderNo: r.order }));
if (updates.length === 0) return;
setReorderError(undefined);
setReordering(true);
Promise.all(updates.map(u => api.templateRouteSteps.patch(route.id, u.id, { orderNo: u.orderNo })))
.then(() => steps.reload())
.catch((e: Error) => setReorderError(texts.routeSteps.reorderFailed(reportError(e, 'RouteStepsScreen: reorder'))))
.then(() => setReordering(false), () => setReordering(false));
};
const moveBlockUp = (blockIndex: number): void => {
if (blockIndex <= 0) return;
applyReorder(current => {
const a = blocks[blockIndex - 1];
const b = blocks[blockIndex];
return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)];
});
};
const moveBlockDown = (blockIndex: number): void => {
if (blockIndex >= blocks.length - 1) return;
applyReorder(current => {
const a = blocks[blockIndex];
const b = blocks[blockIndex + 1];
return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)];
});
};
// Первая строка списка объединяться не с чем — у неё переключатель недоступен.
const toggleParallel = (rowIndex: number): void => {
if (rowIndex <= 0) return;
applyReorder(current => current.map((r, i) => (i === rowIndex ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r)));
};
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
return (
@@ -76,7 +147,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
/>
<PageHeader
icon="flag-check"
subtitle={texts.routeSteps.subtitle(requestTypeName)}
subtitle={texts.routeSteps.subtitle(requestTypeName, companyName, departmentName)}
title={texts.routeSteps.title(route.id)}
actions={
<>
@@ -88,65 +159,131 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{reorderError && <Alert kind="err">{reorderError}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
{
key: 'executor',
header: texts.routeSteps.columnExecutor,
render: r => {
const executor = describeStepExecutor(r, employees.items);
return (
<div>
<PrimaryCell>{executor.primary}</PrimaryCell>
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
</div>
);
}
},
{
key: 'status',
header: texts.routeSteps.columnStatus,
render: r => (
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
)
},
{
key: 'reassign',
header: texts.routeSteps.columnReassign,
align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'condition',
header: texts.routeSteps.columnCondition,
render: r => describeStepCondition(r) ?? texts.common.empty
},
{
key: 'actions',
header: '',
width: '100px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={steps.items}
keyOf={r => r.id}
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
/>
{steps.items.length === 0 && (
<div className={tableStyles.wrap}>
<div className={tableStyles.empty}>{steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}</div>
</div>
)}
{steps.items.length > 0 && (
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
<div className={tableStyles.scroll}>
<table className={tableStyles.table}>
<thead>
<tr>
<th className={`${styles.stepCol} ${styles.stepColSticky}`}>{texts.routeSteps.columnOrderNo}</th>
<th>{texts.routeSteps.columnExecutor}</th>
<th>{texts.routeSteps.columnStatus}</th>
<th style={{ textAlign: 'center' }}>{texts.routeSteps.columnReassign}</th>
<th>{texts.routeSteps.columnCondition}</th>
<th />
<th />
</tr>
</thead>
<tbody>
{withParallel.map((r, i) => {
const blockIndex = blockIndexOf(blocks, i);
const block = blocks[blockIndex];
const isBlockStart = block.start === i;
const executor = describeStepExecutor(r, employees.items);
return (
<tr
key={r.id}
className={blockIndex === hoveredBlock ? styles.rowHover : undefined}
onMouseEnter={() => setHoveredBlock(blockIndex)}
onMouseLeave={() => setHoveredBlock(undefined)}
>
{isBlockStart && (
<td className={`${styles.stepCol} ${styles.stepColSticky}`} rowSpan={block.end - block.start + 1}>
{block.end > block.start ? (
<span className={styles.parallelMark} title={texts.routeSteps.mergeWithPrev}>
{r.order} ∥
</span>
) : (
r.order
)}
</td>
)}
<td>
<PrimaryCell>{executor.primary}</PrimaryCell>
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
</td>
<td>
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
</td>
<td style={{ textAlign: 'center' }}>
{r.allowReassignToOtherApprovers
? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge>
: <Badge variant="neutral">{texts.common.no}</Badge>}
</td>
<td>{describeStepCondition(r) ?? texts.common.empty}</td>
{/* Стрелки — одна пара на весь блок (rowSpan): двигают шаг
целиком, а не отдельного участника параллельной группы. */}
{isBlockStart && (
<td rowSpan={block.end - block.start + 1}>
<ActionsCell>
<IconButton
icon="chevron-down"
className={`${styles.iconBtnVisible} ${styles.flipY}`}
label={texts.routeSteps.moveUp}
disabled={reordering || blockIndex <= 0}
onClick={() => moveBlockUp(blockIndex)}
/>
<IconButton
icon="chevron-down"
className={styles.iconBtnVisible}
label={texts.routeSteps.moveDown}
disabled={reordering || blockIndex >= blocks.length - 1}
onClick={() => moveBlockDown(blockIndex)}
/>
</ActionsCell>
</td>
)}
<td>
<ActionsCell>
<IconButton
icon="people"
className={r.parallelWithPrev ? styles.iconBtnActive : styles.iconBtnVisible}
label={r.parallelWithPrev ? texts.routeSteps.splitFromPrev : texts.routeSteps.mergeWithPrev}
disabled={reordering || i === 0}
onClick={() => toggleParallel(i)}
/>
<IconButton
icon="edit"
className={styles.iconBtnVisible}
label={texts.common.edit}
onClick={() => openEdit(r)}
/>
<IconButton
icon="trash"
className={styles.iconBtnVisible}
label={texts.common.remove}
danger
onClick={() => deleteTarget.ask(r)}
/>
</ActionsCell>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{showForm && (
<RouteStepFormModal
// Undefined, если у маршрута компания не задана (запасной маршрут,
// применяется к любой компании инициатора) — тогда подсказка
// должности показывает всех, как раньше, а не сужает по компании.
routeCompanyName={route.companyId ? companyName : undefined}
api={api}
route={route}
requestType={requestType}
@@ -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';
@@ -10,6 +11,8 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { replaceRoute } from '../../routing';
import {
ActionsCell,
Alert,
@@ -28,18 +31,46 @@ import {
export interface IRoutesScreenProps {
api: ApprovalRouteApi;
dictionary?: PortalDictionaryClient;
/** Фильтр из ссылки (aarfilter) — перекрывает сохранённый, если задан. */
initialFilterRequestTypeId?: string;
onOpenSteps: (route: TemplateRoute) => void;
}
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
api,
dictionary,
initialFilterRequestTypeId,
onOpenSteps
}) => {
const requestTypes = useRequestTypes(api);
const portalDictionaries = usePortalDictionaries(dictionary);
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState('');
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ROUTES_FILTER_STORAGE_KEY, '');
// Открыли по ссылке с фильтром — она сильнее сохранённого значения. Только на
// монтировании: дальше источник истины — сам экран (он же пишет фильтр в URL
// ниже), иначе собственный replaceRoute тут же вернулся бы сюда как «внешний».
React.useEffect(() => {
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Держим URL синхронным с фильтром — ссылку на отфильтрованный список можно
// скопировать. replaceRoute, а не navigate: смена фильтра не должна плодить
// записи в истории браузера.
React.useEffect(() => {
replaceRoute({ tab: 'routes', routesFilter: filterRequestTypeId });
}, [filterRequestTypeId]);
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
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);
@@ -87,6 +118,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
<DataTable
columns={[
{ key: 'id', header: texts.routes.columnId, width: '90px', render: r => `#${r.id}` },
{
key: 'requestType',
header: texts.routes.columnRequestType,
@@ -115,6 +147,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
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>
@@ -140,6 +173,19 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
/>
)}
{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}