Редактирование заявок у конкретного пользователя #14
@@ -69,6 +69,12 @@ export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNaviga
|
|||||||
description={texts.dashboard.manageRequestsCard.description}
|
description={texts.dashboard.manageRequestsCard.description}
|
||||||
onClick={() => onNavigate('manageRequests')}
|
onClick={() => onNavigate('manageRequests')}
|
||||||
/>
|
/>
|
||||||
|
<NavCard
|
||||||
|
icon="people"
|
||||||
|
title={texts.dashboard.manageByUserCard.title}
|
||||||
|
description={texts.dashboard.manageByUserCard.description}
|
||||||
|
onClick={() => onNavigate('manageByUser')}
|
||||||
|
/>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Employee } from '../../models';
|
|
||||||
import { texts } from '../../texts';
|
|
||||||
import { toEmployeeOption } from '../../utils/employee';
|
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
|
||||||
import { Field, FormGrid, FormModal, SearchableSelect } from '../ui';
|
|
||||||
|
|
||||||
export interface IManageByUserReplaceModalProps {
|
|
||||||
title: string;
|
|
||||||
employees: Employee[];
|
|
||||||
employeesLoading: boolean;
|
|
||||||
onSave: (email: string) => Promise<void>;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выбор НОВОГО согласующего/исполнителя для замены (точечной или массовой) —
|
|
||||||
* урезанная версия ManageRequestsApproverModal: здесь нет SLA-полей (это не
|
|
||||||
* правка конкретного шага заявки, а массовая подмена человека), должность
|
|
||||||
* резолвится вызывающей стороной (см. ManageByUserScreen) там, где она вообще
|
|
||||||
* нужна — для живых заявок, не для шаблонов (там ExecutorEmail/Position
|
|
||||||
* взаимоисключающие, трогать Position нельзя).
|
|
||||||
*/
|
|
||||||
export const ManageByUserReplaceModal: React.FC<IManageByUserReplaceModalProps> = ({
|
|
||||||
title,
|
|
||||||
employees,
|
|
||||||
employeesLoading,
|
|
||||||
onSave,
|
|
||||||
onClose
|
|
||||||
}) => {
|
|
||||||
const [email, setEmail] = React.useState('');
|
|
||||||
const submit = useAsyncSubmit();
|
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
|
||||||
const trimmedEmail = email.trim();
|
|
||||||
if (!trimmedEmail) {
|
|
||||||
submit.fail(texts.manageByUser.validationEmployee);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
submit.run(() => onSave(trimmedEmail));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}>
|
|
||||||
<FormGrid>
|
|
||||||
<Field label={texts.manageByUser.newEmployeeLabel} required full>
|
|
||||||
<SearchableSelect
|
|
||||||
options={employees.map(toEmployeeOption)}
|
|
||||||
value={email}
|
|
||||||
loading={employeesLoading}
|
|
||||||
placeholder={texts.manageByUser.newEmployeePlaceholder}
|
|
||||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
|
||||||
emptyMessage={texts.select.employeesEmpty}
|
|
||||||
onChange={setEmail}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FormGrid>
|
|
||||||
</FormModal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -18,3 +18,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поле «Заменить на» + кнопка «поменять местами» справа от него: кнопка стоит
|
||||||
|
// на уровне САМОГО контрола, а не подписи, поэтому живёт внутри Field, а не
|
||||||
|
// отдельной колонкой сетки.
|
||||||
|
.targetRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего
|
||||||
|
// содержимого, и длинное ФИО в селекте вытолкнет кнопку за край поля.
|
||||||
|
.targetSelect {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './ManageByUserScreen.module.scss';
|
import styles from './ManageByUserScreen.module.scss';
|
||||||
import { ManageByUserReplaceModal } from './ManageByUserReplaceModal';
|
|
||||||
import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal';
|
import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
@@ -12,6 +11,7 @@ import { shortGuid } from '../../utils/guid';
|
|||||||
import { describeStepCondition } from '../../utils/stepExecutor';
|
import { describeStepCondition } from '../../utils/stepExecutor';
|
||||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||||
import { reportError } from '../../utils/errors';
|
import { reportError } from '../../utils/errors';
|
||||||
|
import { mapWithLimit } from '../../utils/parallel';
|
||||||
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
|
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
PageHeader,
|
PageHeader,
|
||||||
Panel,
|
Panel,
|
||||||
PrimaryCell,
|
PrimaryCell,
|
||||||
|
ProgressOverlay,
|
||||||
SearchableSelect,
|
SearchableSelect,
|
||||||
Toast
|
Toast
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
@@ -49,9 +50,16 @@ export interface IManageByUserScreenProps {
|
|||||||
route?: IManageByUserRouteParams;
|
route?: IManageByUserRouteParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выбранный сотрудник переживает перезагрузку страницы — как и выбор заявки в
|
// Выбор (кого меняем и на кого) переживает перезагрузку страницы — как и выбор
|
||||||
// ManageRequestsScreen (тот же приём, отдельный ключ).
|
// заявки в ManageRequestsScreen (тот же приём, отдельный ключ).
|
||||||
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserEmail';
|
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserSelection';
|
||||||
|
|
||||||
|
interface IPersistedSelection {
|
||||||
|
employeeEmail: string;
|
||||||
|
targetEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_SELECTION: IPersistedSelection = { employeeEmail: '', targetEmail: '' };
|
||||||
|
|
||||||
// Активные статусы строки для этого экрана — сознательно уже, чем
|
// Активные статусы строки для этого экрана — сознательно уже, чем
|
||||||
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
|
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
|
||||||
@@ -91,13 +99,19 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
const dictionaries = usePortalDictionaries(dictionary);
|
const dictionaries = usePortalDictionaries(dictionary);
|
||||||
|
|
||||||
const [employeeEmail, setEmployeeEmail] = useLocalStorageState<string>(SELECTION_STORAGE_KEY, '');
|
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
|
||||||
|
const { employeeEmail, targetEmail } = selection;
|
||||||
|
|
||||||
// Открыли по глубокой ссылке (aabuser в URL) — перекрывает то, что было в
|
const selectEmployee = (email: string): void => setSelection(prev => ({ ...prev, employeeEmail: email }));
|
||||||
// localStorage. Только на монтировании: route меняется при каждом собственном
|
const selectTarget = (email: string): void => setSelection(prev => ({ ...prev, targetEmail: email }));
|
||||||
// replaceRoute ниже (тот же приём, что в ManageRequestsScreen/ResolveScreen).
|
const swapEmployees = (): void =>
|
||||||
|
setSelection(prev => ({ employeeEmail: prev.targetEmail, targetEmail: prev.employeeEmail }));
|
||||||
|
|
||||||
|
// Открыли по глубокой ссылке (aabuser/aabtarget в URL) — перекрывает то, что
|
||||||
|
// было в localStorage. Только на монтировании: route меняется при каждом
|
||||||
|
// собственном replaceRoute ниже (тот же приём, что в ManageRequestsScreen).
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (route?.userEmail) setEmployeeEmail(route.userEmail);
|
if (route?.userEmail) setSelection({ employeeEmail: route.userEmail, targetEmail: route.targetEmail });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -107,9 +121,9 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
replaceRoute({
|
replaceRoute({
|
||||||
tab: 'manageByUser',
|
tab: 'manageByUser',
|
||||||
manageByUser: employeeEmail ? { userEmail: employeeEmail } : undefined
|
manageByUser: employeeEmail ? { userEmail: employeeEmail, targetEmail } : undefined
|
||||||
});
|
});
|
||||||
}, [employeeEmail]);
|
}, [employeeEmail, targetEmail]);
|
||||||
|
|
||||||
const partitionsByType = React.useMemo(() => {
|
const partitionsByType = React.useMemo(() => {
|
||||||
const map = new Map<number, RequestListPartition[]>();
|
const map = new Map<number, RequestListPartition[]>();
|
||||||
@@ -192,8 +206,10 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
const toggleAllTemplates = (): void =>
|
const toggleAllTemplates = (): void =>
|
||||||
setCheckedTemplateIds(allTemplatesChecked ? new Set() : new Set(templateRows.map(r => r.id)));
|
setCheckedTemplateIds(allTemplatesChecked ? new Set() : new Set(templateRows.map(r => r.id)));
|
||||||
|
|
||||||
const [replaceScope, setReplaceScope] = React.useState<IReplaceScope | undefined>();
|
|
||||||
const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
|
const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
|
||||||
|
// Пока задан — интерфейс заблокирован подложкой с полосой прогресса
|
||||||
|
// (ProgressOverlay), и запустить вторую замену поверх первой нельзя.
|
||||||
|
const [progress, setProgress] = React.useState<{ done: number; total: number } | undefined>();
|
||||||
|
|
||||||
// Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл
|
// Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл
|
||||||
// замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно
|
// замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно
|
||||||
@@ -204,6 +220,11 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
// читается внутри уже запущенного цикла, и перерисовка ему не нужна.
|
// читается внутри уже запущенного цикла, и перерисовка ему не нужна.
|
||||||
const duplicatePolicy = React.useRef<DuplicateChoice | undefined>(undefined);
|
const duplicatePolicy = React.useRef<DuplicateChoice | undefined>(undefined);
|
||||||
|
|
||||||
|
// Шаг вперёд по полосе прогресса. Вызывается ПЕРЕД обработкой очередной
|
||||||
|
// строки: показывает «делаю N-ю из M», а не «N уже готово».
|
||||||
|
const advanceProgress = (): void =>
|
||||||
|
setProgress(prev => (prev ? { done: Math.min(prev.done + 1, prev.total), total: prev.total } : prev));
|
||||||
|
|
||||||
const askDuplicate = (message: string): Promise<DuplicateChoice> => {
|
const askDuplicate = (message: string): Promise<DuplicateChoice> => {
|
||||||
if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current);
|
if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current);
|
||||||
return new Promise<DuplicateChoice>(resolve => setDuplicatePrompt({ message, resolve }));
|
return new Promise<DuplicateChoice>(resolve => setDuplicatePrompt({ message, resolve }));
|
||||||
@@ -217,100 +238,159 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Замена в живых заявках. Дубль (тот же человек уже стоит в этой же фазе этой
|
* Замена в живых заявках — тремя фазами, чтобы совместить параллельность с
|
||||||
* же заявки) SharePoint сам не запрещает — проверяем до записи, иначе в
|
* интерактивным разрешением дублей:
|
||||||
* маршруте появились бы две строки на одного человека, и та же заявка ушла бы
|
*
|
||||||
* ему на согласование дважды.
|
* 1. ПАРАЛЛЕЛЬНО проверяем каждую строку на дубль (чтение, ничего не пишет)
|
||||||
|
* и заодно параллельно заменяем все «чистые» строки;
|
||||||
|
* 2. ПОСЛЕДОВАТЕЛЬНО спрашиваем пользователя по каждому найденному дублю —
|
||||||
|
* диалог по определению один за раз (параллельно открылось бы N окон);
|
||||||
|
* 3. ПАРАЛЛЕЛЬНО удаляем те строки, по которым выбрали «удалить».
|
||||||
|
*
|
||||||
|
* Дубль (тот же человек уже стоит в этой же фазе этой же заявки) SharePoint
|
||||||
|
* сам не запрещает — если не проверить, в маршруте появились бы две строки на
|
||||||
|
* одного человека, и заявка ушла бы ему на согласование дважды.
|
||||||
*/
|
*/
|
||||||
async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise<IBulkOutcome> {
|
async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise<IBulkOutcome> {
|
||||||
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
||||||
|
|
||||||
for (const row of rows) {
|
// Фаза 1: проверка дублей. Прогресс здесь не двигаем — это ещё не работа по
|
||||||
try {
|
// строке, а подготовка (строка может оказаться и заменена, и удалена).
|
||||||
const duplicate = await requestManage.hasApproverInSamePhase(row, email);
|
const checks = await mapWithLimit(rows, row => requestManage.hasApproverInSamePhase(row, email));
|
||||||
if (duplicate) {
|
|
||||||
const label = `${row.requestUniqueCode || `#${row.ticketId}`} ${row.requestTitle}`;
|
const clean: UserApprovalRow[] = [];
|
||||||
const choice = await askDuplicate(texts.manageByUser.duplicateLiveMessage(label, row.position, email));
|
const conflicting: UserApprovalRow[] = [];
|
||||||
if (choice === 'skip') {
|
checks.forEach((result, i) => {
|
||||||
outcome.skipped += 1;
|
if (!result.ok) {
|
||||||
continue;
|
reportError(result.error, 'ManageByUserScreen: hasApproverInSamePhase');
|
||||||
}
|
outcome.fail += 1;
|
||||||
await requestManage.removeRow(row, row.id);
|
advanceProgress();
|
||||||
outcome.deleted += 1;
|
return;
|
||||||
continue;
|
}
|
||||||
}
|
(result.value ? conflicting : clean).push(rows[i]);
|
||||||
await requestManage.reassignApprover(row, row.id, email, position);
|
});
|
||||||
outcome.ok += 1;
|
|
||||||
} catch (e) {
|
// Фаза 2: «чистые» строки — параллельно, друг от друга не зависят.
|
||||||
reportError(e, 'ManageByUserScreen: bulkReplaceLive');
|
const replaced = await mapWithLimit(
|
||||||
|
clean,
|
||||||
|
row => requestManage.reassignApprover(row, row.id, email, position),
|
||||||
|
{ onSettled: advanceProgress }
|
||||||
|
);
|
||||||
|
replaced.forEach(result => {
|
||||||
|
if (result.ok) outcome.ok += 1;
|
||||||
|
else {
|
||||||
|
reportError(result.error, 'ManageByUserScreen: bulkReplaceLive/reassign');
|
||||||
outcome.fail += 1;
|
outcome.fail += 1;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фаза 3: дубли — спрашиваем по одному, накапливая тех, кого удалять.
|
||||||
|
const toDelete: UserApprovalRow[] = [];
|
||||||
|
for (const row of conflicting) {
|
||||||
|
const label = `${row.requestUniqueCode || `#${row.ticketId}`} ${row.requestTitle}`;
|
||||||
|
const choice = await askDuplicate(texts.manageByUser.duplicateLiveMessage(label, row.position, email));
|
||||||
|
if (choice === 'skip') {
|
||||||
|
outcome.skipped += 1;
|
||||||
|
advanceProgress();
|
||||||
|
} else {
|
||||||
|
toDelete.push(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removed = await mapWithLimit(toDelete, row => requestManage.removeRow(row, row.id), {
|
||||||
|
onSettled: advanceProgress
|
||||||
|
});
|
||||||
|
removed.forEach(result => {
|
||||||
|
if (result.ok) outcome.deleted += 1;
|
||||||
|
else {
|
||||||
|
reportError(result.error, 'ManageByUserScreen: bulkReplaceLive/remove');
|
||||||
|
outcome.fail += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Замена в шаблонах маршрутов. Здесь дубль ловит СЕРВЕР (HasDuplicateAsync в
|
* Замена в шаблонах маршрутов — та же трёхфазная схема, что и для живых
|
||||||
* TemplateRouteStepService → 409) — своей предварительной проверки не делаем,
|
* заявок, но дубль здесь ловит СЕРВЕР (HasDuplicateAsync в
|
||||||
|
* TemplateRouteStepService → 409): своей предварительной проверки не делаем,
|
||||||
* его правило шире email'а (совпадать должны ещё статус и условие появления),
|
* его правило шире email'а (совпадать должны ещё статус и условие появления),
|
||||||
* и дублировать это на клиенте значило бы разъехаться с ним при первой же
|
* и дублировать это на клиенте значило бы разъехаться с ним при первой же
|
||||||
* правке.
|
* правке. Поэтому «проверка» и есть сам PATCH — он идёт параллельно, а
|
||||||
|
* разбираем 409-ые уже после.
|
||||||
*/
|
*/
|
||||||
async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise<IBulkOutcome> {
|
async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise<IBulkOutcome> {
|
||||||
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 };
|
||||||
|
|
||||||
for (const row of rows) {
|
const patched = await mapWithLimit(
|
||||||
try {
|
rows,
|
||||||
await api.templateRouteSteps.patch(row.routeId, row.id, { executorEmail: email });
|
row => api.templateRouteSteps.patch(row.routeId, row.id, { executorEmail: email }),
|
||||||
|
{ onSettled: advanceProgress }
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflicting: UserTemplateStepRow[] = [];
|
||||||
|
patched.forEach((result, i) => {
|
||||||
|
if (result.ok) {
|
||||||
outcome.ok += 1;
|
outcome.ok += 1;
|
||||||
} catch (e) {
|
return;
|
||||||
if ((e as ApiClientError)?.status !== 409) {
|
|
||||||
reportError(e, 'ManageByUserScreen: bulkReplaceTemplates');
|
|
||||||
outcome.fail += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const label = `${row.requestTypeName} — ${row.companyName}`;
|
|
||||||
const choice = await askDuplicate(texts.manageByUser.duplicateTemplateMessage(label, row.orderNo, email));
|
|
||||||
if (choice === 'skip') {
|
|
||||||
outcome.skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await api.templateRouteSteps.remove(row.routeId, row.id);
|
|
||||||
outcome.deleted += 1;
|
|
||||||
} catch (removeError) {
|
|
||||||
reportError(removeError, 'ManageByUserScreen: bulkReplaceTemplates/remove');
|
|
||||||
outcome.fail += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if ((result.error as ApiClientError)?.status === 409) {
|
||||||
|
conflicting.push(rows[i]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reportError(result.error, 'ManageByUserScreen: bulkReplaceTemplates');
|
||||||
|
outcome.fail += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toDelete: UserTemplateStepRow[] = [];
|
||||||
|
for (const row of conflicting) {
|
||||||
|
const label = `${row.requestTypeName} — ${row.companyName}`;
|
||||||
|
const choice = await askDuplicate(texts.manageByUser.duplicateTemplateMessage(label, row.orderNo, email));
|
||||||
|
if (choice === 'skip') outcome.skipped += 1;
|
||||||
|
else toDelete.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removed = await mapWithLimit(toDelete, row => api.templateRouteSteps.remove(row.routeId, row.id));
|
||||||
|
removed.forEach(result => {
|
||||||
|
if (result.ok) outcome.deleted += 1;
|
||||||
|
else {
|
||||||
|
reportError(result.error, 'ManageByUserScreen: bulkReplaceTemplates/remove');
|
||||||
|
outcome.fail += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReplaceConfirm = async (email: string): Promise<void> => {
|
const runReplace = async (scope: IReplaceScope): Promise<void> => {
|
||||||
if (!replaceScope) return;
|
const total = scope.live.length + scope.templates.length;
|
||||||
const position = findEmployeeByEmail(employees.items, email)?.position || '';
|
if (!targetEmail || total === 0 || progress) return;
|
||||||
|
|
||||||
|
const position = findEmployeeByEmail(employees.items, targetEmail)?.position || '';
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
// Политика «…для всех» живёт ровно одну операцию — иначе следующая замена
|
// Политика «…для всех» живёт ровно одну операцию — иначе следующая замена
|
||||||
// молча унаследовала бы решение, принятое для совсем другого списка строк.
|
// молча унаследовала бы решение, принятое для совсем другого списка строк.
|
||||||
duplicatePolicy.current = undefined;
|
duplicatePolicy.current = undefined;
|
||||||
|
setProgress({ done: 0, total });
|
||||||
|
|
||||||
// Модалку выбора нового согласующего закрываем сразу: дальше могут
|
try {
|
||||||
// открываться диалоги дублей, и держать два окна одновременно незачем.
|
if (scope.live.length > 0) {
|
||||||
setReplaceScope(undefined);
|
const r = await bulkReplaceLive(scope.live, targetEmail, position);
|
||||||
|
messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped));
|
||||||
if (replaceScope.live.length > 0) {
|
}
|
||||||
const r = await bulkReplaceLive(replaceScope.live, email, position);
|
if (scope.templates.length > 0) {
|
||||||
messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped));
|
const r = await bulkReplaceTemplates(scope.templates, targetEmail);
|
||||||
}
|
messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped));
|
||||||
if (replaceScope.templates.length > 0) {
|
}
|
||||||
const r = await bulkReplaceTemplates(replaceScope.templates, email);
|
} finally {
|
||||||
messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped));
|
// Снимать блокировку обязательно в finally: если что-то бросит мимо
|
||||||
|
// внутренних try/catch, подложка иначе осталась бы висеть навсегда и
|
||||||
|
// экран стал бы неработоспособен до перезагрузки страницы.
|
||||||
|
setProgress(undefined);
|
||||||
|
duplicatePolicy.current = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicatePolicy.current = undefined;
|
|
||||||
setResultToast({ message: messages.join(' '), kind: 'ok' });
|
setResultToast({ message: messages.join(' '), kind: 'ok' });
|
||||||
setCheckedLiveKeys(new Set());
|
setCheckedLiveKeys(new Set());
|
||||||
setCheckedTemplateIds(new Set());
|
setCheckedTemplateIds(new Set());
|
||||||
@@ -318,6 +398,15 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
templateSteps.reload();
|
templateSteps.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Сам себя заменить нельзя — выбор бессмысленный (и в живых заявках сразу
|
||||||
|
// упёрся бы в проверку дубля), поэтому исходного сотрудника в списке
|
||||||
|
// получателей не показываем вовсе.
|
||||||
|
const targetOptions = employees.items
|
||||||
|
.filter(e => e.email.toLowerCase() !== employeeEmail.toLowerCase())
|
||||||
|
.map(toEmployeeOption);
|
||||||
|
// Пока получатель не выбран, менять нечего — все кнопки замены выключены.
|
||||||
|
const canReplace = !!employeeEmail && !!targetEmail;
|
||||||
|
|
||||||
const liveColumns: IColumn<UserApprovalRow>[] = [
|
const liveColumns: IColumn<UserApprovalRow>[] = [
|
||||||
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedLiveKeys.has(liveRowKey(r))} onChange={() => toggleLive(r)} /> },
|
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedLiveKeys.has(liveRowKey(r))} onChange={() => toggleLive(r)} /> },
|
||||||
{ key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName },
|
{ key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName },
|
||||||
@@ -335,7 +424,7 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
sticky: true,
|
sticky: true,
|
||||||
render: r => (
|
render: r => (
|
||||||
<ActionsCell>
|
<ActionsCell>
|
||||||
<IconButton icon="edit" label={texts.manageByUser.replaceOne} onClick={() => setReplaceScope({ live: [r], templates: [] })} />
|
<IconButton icon="edit" label={texts.manageByUser.replaceOne} disabled={!canReplace} onClick={() => runReplace({ live: [r], templates: [] })} />
|
||||||
</ActionsCell>
|
</ActionsCell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -356,7 +445,7 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
sticky: true,
|
sticky: true,
|
||||||
render: r => (
|
render: r => (
|
||||||
<ActionsCell>
|
<ActionsCell>
|
||||||
<IconButton icon="edit" label={texts.manageByUser.replaceOne} onClick={() => setReplaceScope({ live: [], templates: [r] })} />
|
<IconButton icon="edit" label={texts.manageByUser.replaceOne} disabled={!canReplace} onClick={() => runReplace({ live: [], templates: [r] })} />
|
||||||
</ActionsCell>
|
</ActionsCell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -374,8 +463,8 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
variant="accent"
|
variant="accent"
|
||||||
disabled={!employeeEmail || !hasAnyRows}
|
disabled={!canReplace || !hasAnyRows}
|
||||||
onClick={() => setReplaceScope({ live: liveRows, templates: templateRows })}
|
onClick={() => runReplace({ live: liveRows, templates: templateRows })}
|
||||||
>
|
>
|
||||||
{texts.manageByUser.replaceEverywhere}
|
{texts.manageByUser.replaceEverywhere}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -384,7 +473,7 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint} full>
|
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint}>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={employees.items.map(toEmployeeOption)}
|
options={employees.items.map(toEmployeeOption)}
|
||||||
value={employeeEmail}
|
value={employeeEmail}
|
||||||
@@ -392,9 +481,39 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
placeholder={texts.manageByUser.employeePlaceholder}
|
placeholder={texts.manageByUser.employeePlaceholder}
|
||||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
emptyMessage={texts.select.employeesEmpty}
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
onChange={setEmployeeEmail}
|
onChange={selectEmployee}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{/* Кого ставим вместо — рядом, а не в модалке по кнопке: замена обычно
|
||||||
|
идёт в несколько приёмов (сначала заявки, потом маршруты, потом
|
||||||
|
отдельные строки), и выбирать одного и того же человека заново
|
||||||
|
на каждый приём незачем. */}
|
||||||
|
<Field label={texts.manageByUser.targetLabel} required hint={texts.manageByUser.targetHint}>
|
||||||
|
<div className={styles.targetRow}>
|
||||||
|
<div className={styles.targetSelect}>
|
||||||
|
<SearchableSelect
|
||||||
|
options={targetOptions}
|
||||||
|
value={targetEmail}
|
||||||
|
loading={employees.isLoading}
|
||||||
|
placeholder={texts.manageByUser.targetPlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
allowClear
|
||||||
|
clearLabel={texts.manageByUser.targetClear}
|
||||||
|
onChange={selectTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Обратная замена — обычное продолжение работы («вернуть всё
|
||||||
|
как было», «а теперь наоборот»), и переставлять двух человек
|
||||||
|
руками через два поиска по справочнику ради этого не нужно. */}
|
||||||
|
<IconButton
|
||||||
|
icon="sort"
|
||||||
|
label={texts.manageByUser.swap}
|
||||||
|
disabled={!employeeEmail && !targetEmail}
|
||||||
|
onClick={swapEmployees}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -409,8 +528,8 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={checkedLiveRows.length === 0}
|
disabled={!canReplace || checkedLiveRows.length === 0}
|
||||||
onClick={() => setReplaceScope({ live: checkedLiveRows, templates: [] })}
|
onClick={() => runReplace({ live: checkedLiveRows, templates: [] })}
|
||||||
>
|
>
|
||||||
{texts.manageByUser.replaceSelected(checkedLiveRows.length)}
|
{texts.manageByUser.replaceSelected(checkedLiveRows.length)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -435,8 +554,8 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={checkedTemplateRows.length === 0}
|
disabled={!canReplace || checkedTemplateRows.length === 0}
|
||||||
onClick={() => setReplaceScope({ live: [], templates: checkedTemplateRows })}
|
onClick={() => runReplace({ live: [], templates: checkedTemplateRows })}
|
||||||
>
|
>
|
||||||
{texts.manageByUser.replaceSelected(checkedTemplateRows.length)}
|
{texts.manageByUser.replaceSelected(checkedTemplateRows.length)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -450,14 +569,8 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
|||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{replaceScope && (
|
{progress && (
|
||||||
<ManageByUserReplaceModal
|
<ProgressOverlay label={texts.manageByUser.progressLabel} done={progress.done} total={progress.total} />
|
||||||
title={texts.manageByUser.replaceModalTitle}
|
|
||||||
employees={employees.items}
|
|
||||||
employeesLoading={employees.isLoading}
|
|
||||||
onSave={handleReplaceConfirm}
|
|
||||||
onClose={() => setReplaceScope(undefined)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{duplicatePrompt && (
|
{duplicatePrompt && (
|
||||||
|
|||||||
@@ -50,8 +50,14 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wrap обязателен, а не косметика: кнопки .btn — white-space: nowrap, сжаться по
|
||||||
|
// тексту не могут, а justify-content: flex-end выталкивает не влезающие ВЛЕВО,
|
||||||
|
// за границу .box — и они просто исчезают из виду (так пропадала кнопка «Нет,
|
||||||
|
// пропустить» в диалоге на четыре варианта, см. ManageByUserDuplicateModal).
|
||||||
|
// С переносом лишние кнопки уезжают на вторую строку и остаются доступны.
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 16px 26px 22px;
|
padding: 16px 26px 22px;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может
|
||||||
|
// открыть сама длительная операция (например, разрешение конфликта), должны
|
||||||
|
// быть ПОВЕРХ этой подложки, а не под ней.
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 250;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(10, 27, 48, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 24px 26px;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: $sh-modal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
margin-top: 14px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: $gray-200;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: $grad-gold;
|
||||||
|
transition: width .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общее число шагов заранее неизвестно — бегущая полоса вместо доли.
|
||||||
|
.indeterminate {
|
||||||
|
width: 40%;
|
||||||
|
animation: slide 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(250%); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ProgressOverlay.module.scss';
|
||||||
|
|
||||||
|
export interface IProgressOverlayProps {
|
||||||
|
label: string;
|
||||||
|
/** Сколько шагов уже сделано. */
|
||||||
|
done: number;
|
||||||
|
/** Всего шагов. 0/undefined — полоса «бежит», доля не показывается. */
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальная подложка с полосой прогресса на время длительной пакетной операции:
|
||||||
|
* показывает, что работа идёт и сколько осталось, и заодно физически не даёт
|
||||||
|
* кликать по интерфейсу под ней (иначе можно запустить вторую замену поверх
|
||||||
|
* первой или уйти на другую вкладку посреди записи).
|
||||||
|
*
|
||||||
|
* Закрыть её нельзя намеренно — операция уже пишет в SharePoint/БД, и «отмена»
|
||||||
|
* оставила бы часть строк заменённой, а часть нет, без всякого следа об этом.
|
||||||
|
*/
|
||||||
|
export const ProgressOverlay: React.FC<IProgressOverlayProps> = ({ label, done, total }) => {
|
||||||
|
const percent = total && total > 0 ? Math.min(100, Math.round((done / total) * 100)) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} role="alert" aria-busy="true">
|
||||||
|
<div className={styles.box}>
|
||||||
|
<div className={styles.label}>{label}</div>
|
||||||
|
{total ? <div className={styles.counter}>{`${done} / ${total}`}</div> : undefined}
|
||||||
|
<div className={styles.track}>
|
||||||
|
<div
|
||||||
|
className={`${styles.fill} ${percent === undefined ? styles.indeterminate : ''}`}
|
||||||
|
style={percent === undefined ? undefined : { width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ export * from './IconButton';
|
|||||||
export * from './Badge';
|
export * from './Badge';
|
||||||
export * from './Alert';
|
export * from './Alert';
|
||||||
export * from './Toast';
|
export * from './Toast';
|
||||||
|
export * from './ProgressOverlay';
|
||||||
export * from './Panel';
|
export * from './Panel';
|
||||||
export * from './Breadcrumbs';
|
export * from './Breadcrumbs';
|
||||||
export * from './PageHeader';
|
export * from './PageHeader';
|
||||||
|
|||||||
@@ -41,7 +41,16 @@ describe('parseRoute', () => {
|
|||||||
it('manageByUser с aabuser — email сотрудника читается в manageByUser', () => {
|
it('manageByUser с aabuser — email сотрудника читается в manageByUser', () => {
|
||||||
const route = parseRoute('?aatab=manageByUser&aabuser=ivanov%40tnsu.kz');
|
const route = parseRoute('?aatab=manageByUser&aabuser=ivanov%40tnsu.kz');
|
||||||
expect(route.tab).toBe('manageByUser');
|
expect(route.tab).toBe('manageByUser');
|
||||||
expect(route.manageByUser).toEqual({ userEmail: 'ivanov@tnsu.kz' });
|
expect(route.manageByUser).toEqual({ userEmail: 'ivanov@tnsu.kz', targetEmail: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manageByUser с aabtarget — читается и тот, кем заменяют', () => {
|
||||||
|
const route = parseRoute('?aatab=manageByUser&aabuser=ivanov%40tnsu.kz&aabtarget=petrov%40tnsu.kz');
|
||||||
|
expect(route.manageByUser).toEqual({ userEmail: 'ivanov@tnsu.kz', targetEmail: 'petrov@tnsu.kz' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aabtarget без aabuser игнорируется — сам по себе он экран не определяет', () => {
|
||||||
|
expect(parseRoute('?aatab=manageByUser&aabtarget=petrov%40tnsu.kz').manageByUser).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aabuser учитывается только при tab=manageByUser', () => {
|
it('aabuser учитывается только при tab=manageByUser', () => {
|
||||||
@@ -117,6 +126,13 @@ describe('navigate', () => {
|
|||||||
expect(window.history.length).toBe(initialLength + 1);
|
expect(window.history.length).toBe(initialLength + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trip для manageByUser (оба сотрудника)', () => {
|
||||||
|
navigate({ tab: 'manageByUser', manageByUser: { userEmail: 'ivanov@tnsu.kz', targetEmail: 'petrov@tnsu.kz' } });
|
||||||
|
const roundTripped = parseRoute(window.location.search);
|
||||||
|
expect(roundTripped.tab).toBe('manageByUser');
|
||||||
|
expect(roundTripped.manageByUser).toEqual({ userEmail: 'ivanov@tnsu.kz', targetEmail: 'petrov@tnsu.kz' });
|
||||||
|
});
|
||||||
|
|
||||||
it('round-trip для resolve.departmentId', () => {
|
it('round-trip для resolve.departmentId', () => {
|
||||||
navigate({
|
navigate({
|
||||||
tab: 'resolve',
|
tab: 'resolve',
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export interface IResolveRouteParams {
|
|||||||
export interface IManageByUserRouteParams {
|
export interface IManageByUserRouteParams {
|
||||||
/** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */
|
/** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
/**
|
||||||
|
* На кого заменять. Часть ссылки, а не только состояние экрана: замена обычно
|
||||||
|
* идёт в несколько приёмов (сначала заявки, потом маршруты, потом что-то
|
||||||
|
* отдельной строкой), и выбранного «получателя» незачем терять между ними.
|
||||||
|
* Пусто — получатель ещё не выбран.
|
||||||
|
*/
|
||||||
|
targetEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IManageRequestRouteParams {
|
export interface IManageRequestRouteParams {
|
||||||
@@ -80,7 +87,7 @@ const PARAM_KEYS = [
|
|||||||
'aatab', 'aaroute', 'aarfilter',
|
'aatab', 'aaroute', 'aarfilter',
|
||||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||||
'aamtype', 'aamreq', 'aamlist',
|
'aamtype', 'aamreq', 'aamlist',
|
||||||
'aabuser'
|
'aabuser', 'aabtarget'
|
||||||
];
|
];
|
||||||
|
|
||||||
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
||||||
@@ -141,7 +148,9 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
|||||||
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
||||||
resolve,
|
resolve,
|
||||||
manage,
|
manage,
|
||||||
manageByUser: byUserEmail ? { userEmail: byUserEmail } : undefined
|
manageByUser: byUserEmail
|
||||||
|
? { userEmail: byUserEmail, targetEmail: params.get('aabtarget') || '' }
|
||||||
|
: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +185,7 @@ function buildQuery(route: IAdminRoute): string {
|
|||||||
|
|
||||||
if (route.tab === 'manageByUser' && route.manageByUser?.userEmail) {
|
if (route.tab === 'manageByUser' && route.manageByUser?.userEmail) {
|
||||||
params.set('aabuser', route.manageByUser.userEmail);
|
params.set('aabuser', route.manageByUser.userEmail);
|
||||||
|
if (route.manageByUser.targetEmail) params.set('aabtarget', route.manageByUser.targetEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ export const texts = {
|
|||||||
manageRequestsCard: {
|
manageRequestsCard: {
|
||||||
title: 'Редактирование заявок',
|
title: 'Редактирование заявок',
|
||||||
description: 'Правка маршрута согласования конкретной заявки'
|
description: 'Правка маршрута согласования конкретной заявки'
|
||||||
|
},
|
||||||
|
manageByUserCard: {
|
||||||
|
title: 'Заявки по участнику',
|
||||||
|
description: 'Массовая замена согласующего в заявках и шаблонах маршрутов'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -467,10 +471,12 @@ export const texts = {
|
|||||||
selectAll: 'Выбрать все',
|
selectAll: 'Выбрать все',
|
||||||
deselectAll: 'Снять выделение',
|
deselectAll: 'Снять выделение',
|
||||||
replaceSelected: (count: number): string => `Заменить выбранные (${count})`,
|
replaceSelected: (count: number): string => `Заменить выбранные (${count})`,
|
||||||
replaceModalTitle: 'Новый согласующий',
|
targetLabel: 'Заменить на',
|
||||||
newEmployeeLabel: 'Сотрудник',
|
targetPlaceholder: 'Выберите, кем заменить',
|
||||||
newEmployeePlaceholder: 'Выберите нового согласующего',
|
targetHint: 'Кого поставим вместо выбранного сотрудника. Пока не заполнено, кнопки замены выключены.',
|
||||||
validationEmployee: 'Выберите сотрудника.',
|
targetClear: 'Не выбран',
|
||||||
|
swap: 'Поменять местами',
|
||||||
|
progressLabel: 'Заменяем…',
|
||||||
liveResult: (ok: number, fail: number, deleted: number, skipped: number): string => {
|
liveResult: (ok: number, fail: number, deleted: number, skipped: number): string => {
|
||||||
const parts = [`заменено ${ok}`];
|
const parts = [`заменено ${ok}`];
|
||||||
if (deleted > 0) parts.push(`удалено дублей ${deleted}`);
|
if (deleted > 0) parts.push(`удалено дублей ${deleted}`);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { mapWithLimit } from './parallel';
|
||||||
|
|
||||||
|
describe('mapWithLimit', () => {
|
||||||
|
it('возвращает результаты в порядке входного массива, а не в порядке завершения', async () => {
|
||||||
|
// Первый элемент заведомо медленнее второго — если бы порядок задавало
|
||||||
|
// завершение, они бы поменялись местами.
|
||||||
|
const settled = await mapWithLimit(
|
||||||
|
[30, 0, 10],
|
||||||
|
delay => new Promise<number>(resolve => setTimeout(() => resolve(delay), delay)),
|
||||||
|
{ limit: 3 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settled.map(s => (s.ok ? s.value : 'err'))).toEqual([30, 0, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отказ одного элемента не прерывает остальные', async () => {
|
||||||
|
const settled = await mapWithLimit([1, 2, 3], n =>
|
||||||
|
n === 2 ? Promise.reject(new Error('bang')) : Promise.resolve(n)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settled[0]).toEqual({ ok: true, value: 1 });
|
||||||
|
expect(settled[1].ok).toBe(false);
|
||||||
|
expect(settled[2]).toEqual({ ok: true, value: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не превышает предел одновременных запросов', async () => {
|
||||||
|
let running = 0;
|
||||||
|
let peak = 0;
|
||||||
|
|
||||||
|
await mapWithLimit(
|
||||||
|
[1, 2, 3, 4, 5, 6, 7],
|
||||||
|
() => {
|
||||||
|
running += 1;
|
||||||
|
peak = Math.max(peak, running);
|
||||||
|
return new Promise<void>(resolve =>
|
||||||
|
setTimeout(() => {
|
||||||
|
running -= 1;
|
||||||
|
resolve();
|
||||||
|
}, 5)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ limit: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(peak).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onSettled вызывается по разу на каждый элемент, включая упавшие', async () => {
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
await mapWithLimit([1, 2, 3], n => (n === 2 ? Promise.reject(new Error('bang')) : Promise.resolve(n)), {
|
||||||
|
onSettled: () => {
|
||||||
|
done += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(done).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустой список — пустой результат, fn не вызывается', async () => {
|
||||||
|
const fn = jest.fn();
|
||||||
|
const settled = await mapWithLimit([], fn);
|
||||||
|
|
||||||
|
expect(settled).toEqual([]);
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Параллельный запуск с ограничением одновременных запросов и результатом
|
||||||
|
* «по каждому элементу отдельно».
|
||||||
|
*
|
||||||
|
* Зачем свой велосипед вместо Promise.allSettled: его нет в целевом lib этого
|
||||||
|
* проекта (tsconfig.json — es5/es2015.*, без es2020.promise). Плюс здесь нужен
|
||||||
|
* не только «не падать целиком», но и ПОТОЛОК одновременных запросов: без него
|
||||||
|
* пакетная замена на сотню строк уходит в SharePoint сотней параллельных
|
||||||
|
* запросов и упирается в его троттлинг (429), после чего часть строк не
|
||||||
|
* записывается — то есть «быстрее» превращается в «половина не сохранилась».
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SettledResult<R> = { ok: true; value: R } | { ok: false; error: unknown };
|
||||||
|
|
||||||
|
/** Сколько запросов держим в воздухе по умолчанию — компромисс между скоростью и троттлингом SharePoint. */
|
||||||
|
export const DEFAULT_CONCURRENCY = 5;
|
||||||
|
|
||||||
|
export interface IMapWithLimitOptions {
|
||||||
|
limit?: number;
|
||||||
|
/** Вызывается после завершения КАЖДОГО элемента — для полосы прогресса. */
|
||||||
|
onSettled?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пропускает items через fn, держа не больше limit запросов одновременно.
|
||||||
|
* Результат — в том же порядке, что items; отказ одного элемента не прерывает
|
||||||
|
* остальные (см. SettledResult).
|
||||||
|
*/
|
||||||
|
export async function mapWithLimit<T, R>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T, index: number) => Promise<R>,
|
||||||
|
options: IMapWithLimitOptions = {}
|
||||||
|
): Promise<Array<SettledResult<R>>> {
|
||||||
|
const limit = Math.max(1, options.limit ?? DEFAULT_CONCURRENCY);
|
||||||
|
const results: Array<SettledResult<R>> = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
|
||||||
|
// Ровно limit «рабочих», каждый в цикле забирает следующий свободный индекс.
|
||||||
|
// Так предел соблюдается точно, без пауз между пачками (в отличие от разбиения
|
||||||
|
// на чанки, где приходится ждать самый медленный элемент каждой пачки).
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
for (;;) {
|
||||||
|
const index = next;
|
||||||
|
next += 1;
|
||||||
|
if (index >= items.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
results[index] = { ok: true, value: await fn(items[index], index) };
|
||||||
|
} catch (error) {
|
||||||
|
results[index] = { ok: false, error };
|
||||||
|
}
|
||||||
|
if (options.onSettled) options.onSettled();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(new Array(Math.min(limit, items.length)).fill(0).map(worker));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user