Обходной лист (#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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user