diff --git a/frontend/src/webparts/approvalAdmin/components/screens/DashboardScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/DashboardScreen.tsx index d9664fd..2451be1 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/DashboardScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/DashboardScreen.tsx @@ -69,6 +69,12 @@ export const DashboardScreen: React.FC = ({ api, onNaviga description={texts.dashboard.manageRequestsCard.description} onClick={() => onNavigate('manageRequests')} /> + onNavigate('manageByUser')} + /> ); diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserReplaceModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserReplaceModal.tsx deleted file mode 100644 index 4eddea7..0000000 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserReplaceModal.tsx +++ /dev/null @@ -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; - onClose: () => void; -} - -/** - * Выбор НОВОГО согласующего/исполнителя для замены (точечной или массовой) — - * урезанная версия ManageRequestsApproverModal: здесь нет SLA-полей (это не - * правка конкретного шага заявки, а массовая подмена человека), должность - * резолвится вызывающей стороной (см. ManageByUserScreen) там, где она вообще - * нужна — для живых заявок, не для шаблонов (там ExecutorEmail/Position - * взаимоисключающие, трогать Position нельзя). - */ -export const ManageByUserReplaceModal: React.FC = ({ - 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 ( - - - - - - - - ); -}; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss index 06ec689..869079b 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss @@ -18,3 +18,19 @@ align-items: center; gap: 8px; } + +// Поле «Заменить на» + кнопка «поменять местами» справа от него: кнопка стоит +// на уровне САМОГО контрола, а не подписи, поэтому живёт внутри Field, а не +// отдельной колонкой сетки. +.targetRow { + display: flex; + align-items: center; + gap: 8px; +} + +// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего +// содержимого, и длинное ФИО в селекте вытолкнет кнопку за край поля. +.targetSelect { + flex: 1 1 auto; + min-width: 0; +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx index 95b9622..2dadb71 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import styles from './ManageByUserScreen.module.scss'; -import { ManageByUserReplaceModal } from './ManageByUserReplaceModal'; import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; @@ -12,6 +11,7 @@ import { shortGuid } from '../../utils/guid'; import { describeStepCondition } from '../../utils/stepExecutor'; import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; import { reportError } from '../../utils/errors'; +import { mapWithLimit } from '../../utils/parallel'; import { IManageByUserRouteParams, replaceRoute } from '../../routing'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useRequestTypes } from '../../hooks/useRequestTypes'; @@ -36,6 +36,7 @@ import { PageHeader, Panel, PrimaryCell, + ProgressOverlay, SearchableSelect, Toast } from '../ui'; @@ -49,9 +50,16 @@ export interface IManageByUserScreenProps { route?: IManageByUserRouteParams; } -// Выбранный сотрудник переживает перезагрузку страницы — как и выбор заявки в -// ManageRequestsScreen (тот же приём, отдельный ключ). -const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserEmail'; +// Выбор (кого меняем и на кого) переживает перезагрузку страницы — как и выбор +// заявки в ManageRequestsScreen (тот же приём, отдельный ключ). +const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserSelection'; + +interface IPersistedSelection { + employeeEmail: string; + targetEmail: string; +} + +const EMPTY_SELECTION: IPersistedSelection = { employeeEmail: '', targetEmail: '' }; // Активные статусы строки для этого экрана — сознательно уже, чем // isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и @@ -91,13 +99,19 @@ export const ManageByUserScreen: React.FC = ({ api, di const employees = usePortalEmployees(dictionary); const dictionaries = usePortalDictionaries(dictionary); - const [employeeEmail, setEmployeeEmail] = useLocalStorageState(SELECTION_STORAGE_KEY, ''); + const [selection, setSelection] = useLocalStorageState(SELECTION_STORAGE_KEY, EMPTY_SELECTION); + const { employeeEmail, targetEmail } = selection; - // Открыли по глубокой ссылке (aabuser в URL) — перекрывает то, что было в - // localStorage. Только на монтировании: route меняется при каждом собственном - // replaceRoute ниже (тот же приём, что в ManageRequestsScreen/ResolveScreen). + const selectEmployee = (email: string): void => setSelection(prev => ({ ...prev, employeeEmail: email })); + const selectTarget = (email: string): void => setSelection(prev => ({ ...prev, targetEmail: email })); + const swapEmployees = (): void => + setSelection(prev => ({ employeeEmail: prev.targetEmail, targetEmail: prev.employeeEmail })); + + // Открыли по глубокой ссылке (aabuser/aabtarget в URL) — перекрывает то, что + // было в localStorage. Только на монтировании: route меняется при каждом + // собственном replaceRoute ниже (тот же приём, что в ManageRequestsScreen). 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 }, []); @@ -107,9 +121,9 @@ export const ManageByUserScreen: React.FC = ({ api, di React.useEffect(() => { replaceRoute({ tab: 'manageByUser', - manageByUser: employeeEmail ? { userEmail: employeeEmail } : undefined + manageByUser: employeeEmail ? { userEmail: employeeEmail, targetEmail } : undefined }); - }, [employeeEmail]); + }, [employeeEmail, targetEmail]); const partitionsByType = React.useMemo(() => { const map = new Map(); @@ -192,8 +206,10 @@ export const ManageByUserScreen: React.FC = ({ api, di const toggleAllTemplates = (): void => setCheckedTemplateIds(allTemplatesChecked ? new Set() : new Set(templateRows.map(r => r.id))); - const [replaceScope, setReplaceScope] = React.useState(); const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>(); + // Пока задан — интерфейс заблокирован подложкой с полосой прогресса + // (ProgressOverlay), и запустить вторую замену поверх первой нельзя. + const [progress, setProgress] = React.useState<{ done: number; total: number } | undefined>(); // Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл // замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно @@ -204,6 +220,11 @@ export const ManageByUserScreen: React.FC = ({ api, di // читается внутри уже запущенного цикла, и перерисовка ему не нужна. const duplicatePolicy = React.useRef(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 => { if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current); return new Promise(resolve => setDuplicatePrompt({ message, resolve })); @@ -217,100 +238,159 @@ export const ManageByUserScreen: React.FC = ({ api, di }; /** - * Замена в живых заявках. Дубль (тот же человек уже стоит в этой же фазе этой - * же заявки) SharePoint сам не запрещает — проверяем до записи, иначе в - * маршруте появились бы две строки на одного человека, и та же заявка ушла бы - * ему на согласование дважды. + * Замена в живых заявках — тремя фазами, чтобы совместить параллельность с + * интерактивным разрешением дублей: + * + * 1. ПАРАЛЛЕЛЬНО проверяем каждую строку на дубль (чтение, ничего не пишет) + * и заодно параллельно заменяем все «чистые» строки; + * 2. ПОСЛЕДОВАТЕЛЬНО спрашиваем пользователя по каждому найденному дублю — + * диалог по определению один за раз (параллельно открылось бы N окон); + * 3. ПАРАЛЛЕЛЬНО удаляем те строки, по которым выбрали «удалить». + * + * Дубль (тот же человек уже стоит в этой же фазе этой же заявки) SharePoint + * сам не запрещает — если не проверить, в маршруте появились бы две строки на + * одного человека, и заявка ушла бы ему на согласование дважды. */ async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise { const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 }; - for (const row of rows) { - try { - const duplicate = await requestManage.hasApproverInSamePhase(row, email); - if (duplicate) { - 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; - continue; - } - await requestManage.removeRow(row, row.id); - outcome.deleted += 1; - continue; - } - await requestManage.reassignApprover(row, row.id, email, position); - outcome.ok += 1; - } catch (e) { - reportError(e, 'ManageByUserScreen: bulkReplaceLive'); + // Фаза 1: проверка дублей. Прогресс здесь не двигаем — это ещё не работа по + // строке, а подготовка (строка может оказаться и заменена, и удалена). + const checks = await mapWithLimit(rows, row => requestManage.hasApproverInSamePhase(row, email)); + + const clean: UserApprovalRow[] = []; + const conflicting: UserApprovalRow[] = []; + checks.forEach((result, i) => { + if (!result.ok) { + reportError(result.error, 'ManageByUserScreen: hasApproverInSamePhase'); + outcome.fail += 1; + advanceProgress(); + return; + } + (result.value ? conflicting : clean).push(rows[i]); + }); + + // Фаза 2: «чистые» строки — параллельно, друг от друга не зависят. + 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; } + }); + + // Фаза 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; } /** - * Замена в шаблонах маршрутов. Здесь дубль ловит СЕРВЕР (HasDuplicateAsync в - * TemplateRouteStepService → 409) — своей предварительной проверки не делаем, + * Замена в шаблонах маршрутов — та же трёхфазная схема, что и для живых + * заявок, но дубль здесь ловит СЕРВЕР (HasDuplicateAsync в + * TemplateRouteStepService → 409): своей предварительной проверки не делаем, * его правило шире email'а (совпадать должны ещё статус и условие появления), * и дублировать это на клиенте значило бы разъехаться с ним при первой же - * правке. + * правке. Поэтому «проверка» и есть сам PATCH — он идёт параллельно, а + * разбираем 409-ые уже после. */ async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise { const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 }; - for (const row of rows) { - try { - await api.templateRouteSteps.patch(row.routeId, row.id, { executorEmail: email }); + const patched = await mapWithLimit( + rows, + 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; - } catch (e) { - 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; - } + return; } + 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; } - const handleReplaceConfirm = async (email: string): Promise => { - if (!replaceScope) return; - const position = findEmployeeByEmail(employees.items, email)?.position || ''; + const runReplace = async (scope: IReplaceScope): Promise => { + const total = scope.live.length + scope.templates.length; + if (!targetEmail || total === 0 || progress) return; + + const position = findEmployeeByEmail(employees.items, targetEmail)?.position || ''; const messages: string[] = []; // Политика «…для всех» живёт ровно одну операцию — иначе следующая замена // молча унаследовала бы решение, принятое для совсем другого списка строк. duplicatePolicy.current = undefined; + setProgress({ done: 0, total }); - // Модалку выбора нового согласующего закрываем сразу: дальше могут - // открываться диалоги дублей, и держать два окна одновременно незачем. - setReplaceScope(undefined); - - if (replaceScope.live.length > 0) { - const r = await bulkReplaceLive(replaceScope.live, email, position); - messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped)); - } - if (replaceScope.templates.length > 0) { - const r = await bulkReplaceTemplates(replaceScope.templates, email); - messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped)); + try { + if (scope.live.length > 0) { + const r = await bulkReplaceLive(scope.live, targetEmail, position); + messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped)); + } + if (scope.templates.length > 0) { + const r = await bulkReplaceTemplates(scope.templates, targetEmail); + messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped)); + } + } finally { + // Снимать блокировку обязательно в finally: если что-то бросит мимо + // внутренних try/catch, подложка иначе осталась бы висеть навсегда и + // экран стал бы неработоспособен до перезагрузки страницы. + setProgress(undefined); + duplicatePolicy.current = undefined; } - duplicatePolicy.current = undefined; setResultToast({ message: messages.join(' '), kind: 'ok' }); setCheckedLiveKeys(new Set()); setCheckedTemplateIds(new Set()); @@ -318,6 +398,15 @@ export const ManageByUserScreen: React.FC = ({ api, di templateSteps.reload(); }; + // Сам себя заменить нельзя — выбор бессмысленный (и в живых заявках сразу + // упёрся бы в проверку дубля), поэтому исходного сотрудника в списке + // получателей не показываем вовсе. + const targetOptions = employees.items + .filter(e => e.email.toLowerCase() !== employeeEmail.toLowerCase()) + .map(toEmployeeOption); + // Пока получатель не выбран, менять нечего — все кнопки замены выключены. + const canReplace = !!employeeEmail && !!targetEmail; + const liveColumns: IColumn[] = [ { key: 'check', header: '', width: '32px', render: r => toggleLive(r)} /> }, { key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName }, @@ -335,7 +424,7 @@ export const ManageByUserScreen: React.FC = ({ api, di sticky: true, render: r => ( - setReplaceScope({ live: [r], templates: [] })} /> + runReplace({ live: [r], templates: [] })} /> ) } @@ -356,7 +445,7 @@ export const ManageByUserScreen: React.FC = ({ api, di sticky: true, render: r => ( - setReplaceScope({ live: [], templates: [r] })} /> + runReplace({ live: [], templates: [r] })} /> ) } @@ -374,8 +463,8 @@ export const ManageByUserScreen: React.FC = ({ api, di actions={ @@ -384,7 +473,7 @@ export const ManageByUserScreen: React.FC = ({ api, di - + = ({ api, di placeholder={texts.manageByUser.employeePlaceholder} searchPlaceholder={texts.select.employeeSearchPlaceholder} emptyMessage={texts.select.employeesEmpty} - onChange={setEmployeeEmail} + onChange={selectEmployee} /> + {/* Кого ставим вместо — рядом, а не в модалке по кнопке: замена обычно + идёт в несколько приёмов (сначала заявки, потом маршруты, потом + отдельные строки), и выбирать одного и того же человека заново + на каждый приём незачем. */} + +
+
+ +
+ {/* Обратная замена — обычное продолжение работы («вернуть всё + как было», «а теперь наоборот»), и переставлять двух человек + руками через два поиска по справочнику ради этого не нужно. */} + +
+
@@ -409,8 +528,8 @@ export const ManageByUserScreen: React.FC = ({ api, di @@ -435,8 +554,8 @@ export const ManageByUserScreen: React.FC = ({ api, di @@ -450,14 +569,8 @@ export const ManageByUserScreen: React.FC = ({ api, di )} - {replaceScope && ( - setReplaceScope(undefined)} - /> + {progress && ( + )} {duplicatePrompt && ( diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss index 4300f62..855ab6e 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss @@ -50,8 +50,14 @@ line-height: 1.6; } +// wrap обязателен, а не косметика: кнопки .btn — white-space: nowrap, сжаться по +// тексту не могут, а justify-content: flex-end выталкивает не влезающие ВЛЕВО, +// за границу .box — и они просто исчезают из виду (так пропадала кнопка «Нет, +// пропустить» в диалоге на четыре варианта, см. ManageByUserDuplicateModal). +// С переносом лишние кнопки уезжают на вторую строку и остаются доступны. .footer { display: flex; + flex-wrap: wrap; justify-content: flex-end; gap: 10px; padding: 16px 26px 22px; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.module.scss new file mode 100644 index 0000000..a6068ca --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.module.scss @@ -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%); } +} diff --git a/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.tsx b/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.tsx new file mode 100644 index 0000000..d33f1cc --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.tsx @@ -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 = ({ label, done, total }) => { + const percent = total && total > 0 ? Math.min(100, Math.round((done / total) * 100)) : undefined; + + return ( +
+
+
{label}
+ {total ?
{`${done} / ${total}`}
: undefined} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/index.ts b/frontend/src/webparts/approvalAdmin/components/ui/index.ts index 34e461a..6dd1176 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/index.ts +++ b/frontend/src/webparts/approvalAdmin/components/ui/index.ts @@ -4,6 +4,7 @@ export * from './IconButton'; export * from './Badge'; export * from './Alert'; export * from './Toast'; +export * from './ProgressOverlay'; export * from './Panel'; export * from './Breadcrumbs'; export * from './PageHeader'; diff --git a/frontend/src/webparts/approvalAdmin/routing.test.ts b/frontend/src/webparts/approvalAdmin/routing.test.ts index efabd81..7935db7 100644 --- a/frontend/src/webparts/approvalAdmin/routing.test.ts +++ b/frontend/src/webparts/approvalAdmin/routing.test.ts @@ -41,7 +41,16 @@ describe('parseRoute', () => { it('manageByUser с aabuser — email сотрудника читается в manageByUser', () => { const route = parseRoute('?aatab=manageByUser&aabuser=ivanov%40tnsu.kz'); 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', () => { @@ -117,6 +126,13 @@ describe('navigate', () => { 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', () => { navigate({ tab: 'resolve', diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index 5ce28e1..dd0d996 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -36,6 +36,13 @@ export interface IResolveRouteParams { export interface IManageByUserRouteParams { /** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */ userEmail: string; + /** + * На кого заменять. Часть ссылки, а не только состояние экрана: замена обычно + * идёт в несколько приёмов (сначала заявки, потом маршруты, потом что-то + * отдельной строкой), и выбранного «получателя» незачем терять между ними. + * Пусто — получатель ещё не выбран. + */ + targetEmail: string; } export interface IManageRequestRouteParams { @@ -80,7 +87,7 @@ const PARAM_KEYS = [ 'aatab', 'aaroute', 'aarfilter', 'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept', 'aamtype', 'aamreq', 'aamlist', - 'aabuser' + 'aabuser', 'aabtarget' ]; // 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, resolve, 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) { params.set('aabuser', route.manageByUser.userEmail); + if (route.manageByUser.targetEmail) params.set('aabtarget', route.manageByUser.targetEmail); } const query = params.toString(); diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 389dcbe..669ccfb 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -107,6 +107,10 @@ export const texts = { manageRequestsCard: { title: 'Редактирование заявок', description: 'Правка маршрута согласования конкретной заявки' + }, + manageByUserCard: { + title: 'Заявки по участнику', + description: 'Массовая замена согласующего в заявках и шаблонах маршрутов' } }, @@ -467,10 +471,12 @@ export const texts = { selectAll: 'Выбрать все', deselectAll: 'Снять выделение', replaceSelected: (count: number): string => `Заменить выбранные (${count})`, - replaceModalTitle: 'Новый согласующий', - newEmployeeLabel: 'Сотрудник', - newEmployeePlaceholder: 'Выберите нового согласующего', - validationEmployee: 'Выберите сотрудника.', + targetLabel: 'Заменить на', + targetPlaceholder: 'Выберите, кем заменить', + targetHint: 'Кого поставим вместо выбранного сотрудника. Пока не заполнено, кнопки замены выключены.', + targetClear: 'Не выбран', + swap: 'Поменять местами', + progressLabel: 'Заменяем…', liveResult: (ok: number, fail: number, deleted: number, skipped: number): string => { const parts = [`заменено ${ok}`]; if (deleted > 0) parts.push(`удалено дублей ${deleted}`); diff --git a/frontend/src/webparts/approvalAdmin/utils/parallel.test.ts b/frontend/src/webparts/approvalAdmin/utils/parallel.test.ts new file mode 100644 index 0000000..b47bf88 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/utils/parallel.test.ts @@ -0,0 +1,67 @@ +import { mapWithLimit } from './parallel'; + +describe('mapWithLimit', () => { + it('возвращает результаты в порядке входного массива, а не в порядке завершения', async () => { + // Первый элемент заведомо медленнее второго — если бы порядок задавало + // завершение, они бы поменялись местами. + const settled = await mapWithLimit( + [30, 0, 10], + delay => new Promise(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(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(); + }); +}); diff --git a/frontend/src/webparts/approvalAdmin/utils/parallel.ts b/frontend/src/webparts/approvalAdmin/utils/parallel.ts new file mode 100644 index 0000000..f102c26 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/utils/parallel.ts @@ -0,0 +1,59 @@ +/** + * Параллельный запуск с ограничением одновременных запросов и результатом + * «по каждому элементу отдельно». + * + * Зачем свой велосипед вместо Promise.allSettled: его нет в целевом lib этого + * проекта (tsconfig.json — es5/es2015.*, без es2020.promise). Плюс здесь нужен + * не только «не падать целиком», но и ПОТОЛОК одновременных запросов: без него + * пакетная замена на сотню строк уходит в SharePoint сотней параллельных + * запросов и упирается в его троттлинг (429), после чего часть строк не + * записывается — то есть «быстрее» превращается в «половина не сохранилась». + */ + +export type SettledResult = { 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( + items: T[], + fn: (item: T, index: number) => Promise, + options: IMapWithLimitOptions = {} +): Promise>> { + const limit = Math.max(1, options.limit ?? DEFAULT_CONCURRENCY); + const results: Array> = new Array(items.length); + let next = 0; + + // Ровно limit «рабочих», каждый в цикле забирает следующий свободный индекс. + // Так предел соблюдается точно, без пауз между пачками (в отличие от разбиения + // на чанки, где приходится ждать самый медленный элемент каждой пачки). + const worker = async (): Promise => { + 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; +}