From 1ee1b71e3ec6577a524bf7fe75dac5681a601c9e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 12:58:07 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA=20=D1=83=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/14 --- .../TemplateRouteStepSearchController.cs | 25 + .../Reminders/ReminderService.cs | 35 +- .../TemplateRouteStepService.cs | 23 + .../TemplateRouteStepServiceTests.cs | 40 ++ frontend/.npmrc | 1 + frontend/package-lock.json | 13 + frontend/package.json | 3 +- .../ApprovalAdminWebPart.manifest.json | 2 +- .../approvalAdmin/ApprovalAdminWebPart.ts | 4 - .../components/app/AdminApp.module.scss | 44 +- .../approvalAdmin/components/app/AdminApp.tsx | 145 +++-- .../components/app/TopNav.module.scss | 76 --- .../approvalAdmin/components/app/TopNav.tsx | 95 --- .../approvalAdmin/components/app/types.ts | 2 +- .../components/screens/DashboardScreen.tsx | 6 + .../screens/ManageByUserDuplicateModal.tsx | 34 + .../screens/ManageByUserScreen.module.scss | 36 ++ .../components/screens/ManageByUserScreen.tsx | 589 ++++++++++++++++++ .../screens/ManageRequestsScreen.tsx | 82 ++- .../components/ui/Modal.module.scss | 6 + .../components/ui/ProgressOverlay.module.scss | 62 ++ .../components/ui/ProgressOverlay.tsx | 38 ++ .../approvalAdmin/components/ui/index.ts | 1 + .../hooks/useRequestTypeListPartitions.ts | 20 + .../hooks/useTemplateRouteStepsByExecutor.ts | 16 + .../hooks/useTemplateRoutesLookup.ts | 54 ++ .../webparts/approvalAdmin/models/index.ts | 1 + .../approvalAdmin/models/manageByUser.ts | 31 + .../approvalAdmin/models/requestManage.ts | 15 + .../webparts/approvalAdmin/routing.test.ts | 47 +- .../src/webparts/approvalAdmin/routing.ts | 42 +- .../webparts/approvalAdmin/services/api.ts | 17 +- .../services/requestManageService.ts | 396 +++++++++--- frontend/src/webparts/approvalAdmin/texts.ts | 68 +- .../approvalAdmin/utils/parallel.test.ts | 67 ++ .../webparts/approvalAdmin/utils/parallel.ts | 59 ++ 36 files changed, 1862 insertions(+), 333 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepSearchController.cs create mode 100644 frontend/.npmrc delete mode 100644 frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss delete mode 100644 frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageByUserDuplicateModal.tsx create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx create mode 100644 frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.module.scss create mode 100644 frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.tsx create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListPartitions.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useTemplateRouteStepsByExecutor.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useTemplateRoutesLookup.ts create mode 100644 frontend/src/webparts/approvalAdmin/models/manageByUser.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/parallel.test.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/parallel.ts diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepSearchController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepSearchController.cs new file mode 100644 index 0000000..e93f3b6 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepSearchController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +/// +/// Поиск шагов шаблонов маршрутов НЕ по конкретному маршруту, а сквозной, по всем +/// маршрутам разом — отдельный top-level контроллер, а не вложенный под +/// /api/template-routes/{routeId}/steps (тот же приём, что RequestTypeListsController +/// отдельно от RequestTypesController). +/// +[ApiController] +[Authorize] +[Route("api/template-route-steps")] +[Produces("application/json")] +public sealed class TemplateRouteStepSearchController(TemplateRouteStepService service) : ControllerBase +{ + /// Шаги ВСЕХ маршрутов с данным ExecutorEmail — для экрана «заменить согласующего everywhere». + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task List([FromQuery] string executorEmail, CancellationToken ct) => + (await service.ListByExecutorEmailAsync(executorEmail, ct)).ToActionResult(); +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 5c08ecc..0d29747 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -40,20 +40,34 @@ public sealed class ReminderService( .Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); - // Типы независимы (разные сайты/списки в SharePoint) — их обход - // распараллелен, иначе время растёт линейно с числом типов: каждый тип - // сам по себе делает несколько последовательных HTTP-запросов. - var results = await Task.WhenAll(types.Select(type => GetOverdueForTypeSafeAsync(type, now, cancellationToken))); + // Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним DbContext — он не + // потокобезопасен, и параллельный вызов GetArchivedAsync из нескольких + // веток Task.WhenAll ниже валит его исключением ("A second operation + // was started on this context instance before a previous operation + // completed"), которое try/catch в GetOverdueForTypeSafeAsync тихо + // проглатывает — часть напоминаний молча пропадала бы без всякой + // видимой ошибки. Сама выборка — без HTTP, быстрая, последовательность + // здесь не стоит ощутимого времени. + var archivedByType = new Dictionary>(); + foreach (var type in types) + archivedByType[type.Id] = await partitionRegistry.GetArchivedAsync(type, cancellationToken); + + // Дальше типы независимы (разные сайты/списки в SharePoint, БД уже не + // трогают) — обход распараллелен, иначе время растёт линейно с числом + // типов: каждый тип сам по себе делает несколько последовательных + // HTTP-запросов. + var results = await Task.WhenAll( + types.Select(type => GetOverdueForTypeSafeAsync(type, archivedByType[type.Id], now, cancellationToken))); return Result.Ok>(results.SelectMany(r => r).ToList()); } private async Task> GetOverdueForTypeSafeAsync( - RequestType type, DateTimeOffset now, CancellationToken cancellationToken) + RequestType type, IReadOnlyList archived, DateTimeOffset now, CancellationToken cancellationToken) { try { - return await GetOverdueForTypeAsync(type, now, cancellationToken); + return await GetOverdueForTypeAsync(type, archived, now, cancellationToken); } catch (Exception e) { @@ -70,14 +84,13 @@ public sealed class ReminderService( /// списке, и не должна пропадать из напоминаний только из-за того, что для /// новых заявок завели следующий список. /// - /// request_list_partitions хранит только архив (см. ListPartitionRegistry) — - /// текущая партиция синтезируется отдельно и добавляется к архиву здесь же, - /// а не читается из БД. + /// archived — уже прочитан вызывающей стороной (GetOverdueAsync, + /// последовательно для всех типов разом) — здесь БД больше не трогаем, + /// только HTTP к SharePoint, поэтому вызов можно параллелить по типам. /// private async Task> GetOverdueForTypeAsync( - RequestType type, DateTimeOffset now, CancellationToken cancellationToken) + RequestType type, IReadOnlyList archived, DateTimeOffset now, CancellationToken cancellationToken) { - var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); var partitions = archived diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index b2b3654..65d87d4 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -25,6 +25,29 @@ public sealed class TemplateRouteStepService( return Result.Ok>(items); } + /// + /// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для + /// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen): + /// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не + /// попадают — это не привязка к конкретному человеку. + /// + public async Task>> ListByExecutorEmailAsync( + string executorEmail, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(executorEmail)) + return Result.Ok>(Array.Empty()); + + var items = await db.TemplateRouteSteps.AsNoTracking() + .Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail)) + .OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo) + .Select(x => new TemplateRouteStepDto( + x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, + x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + public async Task> GetAsync(int routeId, int id, CancellationToken cancellationToken) { var entity = await db.TemplateRouteSteps.AsNoTracking() diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 05547da..ab4241e 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -511,4 +511,44 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture x.RouteId == routeAId || x.RouteId == routeBId); + } + + [Fact(DisplayName = "ListByExecutorEmailAsync с пустым email возвращает пустой список, а не ошибку")] + public async Task ListByExecutorEmailAsync_EmptyEmail_ReturnsEmptyList() + { + // Action + var result = await _service.ListByExecutorEmailAsync(string.Empty, CancellationToken.None); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeEmpty(); + } } diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..8e18031 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a4f4b7..aeb2ac7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1", "@pnp/sp": "^4.20.0", + "@tnsu/ui-kit": "^0.4.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" @@ -6281,6 +6282,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/@tnsu/ui-kit": { + "version": "0.4.0", + "resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.4.0/ui-kit-0.4.0.tgz", + "integrity": "sha512-J3cBvoR2vV0SC5GihOagLAqvC0txQE342qIh6viabMt+Um6Jg4mhTff+ZkVahG0g+bF6sqSCUl3x813aO82KuQ==", + "license": "ISC", + "peerDependencies": { + "@fluentui/react": "^8.106.4", + "react": "^17.0.1", + "react-dom": "^17.0.1", + "tslib": "^2.3.1" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dfde9d6..657336e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@fluentui/react": "^8.106.4", - "@pnp/sp": "^4.20.0", "@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-http": "1.22.1", @@ -21,6 +20,8 @@ "@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1", + "@pnp/sp": "^4.20.0", + "@tnsu/ui-kit": "^0.4.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json index 9a4f1ab..cb085e8 100644 --- a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json +++ b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json @@ -14,7 +14,7 @@ "requiresCustomScript": false, "supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"], "supportsThemeVariants": true, - + "supportsFullBleed": true, "preconfiguredEntries": [{ "groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced "group": { "default": "Advanced" }, diff --git a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts index 16a8e9b..7fee57f 100644 --- a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts +++ b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts @@ -69,10 +69,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart = ({ api, dictionary, sharePoint const route = useRoute(); const [selectedRoute, setSelectedRoute] = React.useState(); const [selectedRequestType, setSelectedRequestType] = React.useState(); + const [navCollapsed, setNavCollapsed] = React.useState(false); + const [mobileNavOpen, setMobileNavOpen] = React.useState(false); - const navigateTab = (key: ScreenKey): void => navigate({ tab: key }); + const [storedTab, setStoredTab] = useLocalStorageState(TAB_STORAGE_KEY, 'dashboard'); + + // Восстанавливаем последнюю вкладку ТОЛЬКО когда в адресе её нет: глубокая + // ссылка (в т.ч. присланная коллегой) всегда важнее запомненного выбора. + // Один раз на монтировании — дальше вкладку меняет только сам пользователь. + React.useEffect(() => { + const explicitTab = new URLSearchParams(window.location.search).get('aatab'); + if (explicitTab || storedTab === 'dashboard') return; + replaceRoute({ tab: storedTab }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 'routeSteps' сюда не попадёт: useRoute отдаёт только вкладки из TABS, а шаги + // маршрута — состояние вкладки 'routes' (см. routing.ts). + React.useEffect(() => { + setStoredTab(route.tab); + }, [route.tab, setStoredTab]); + + const navigateTab = (key: ScreenKey): void => { + setMobileNavOpen(false); + navigate({ tab: key }); + }; const openSteps = (r: TemplateRoute): void => { setSelectedRoute(r); @@ -76,41 +127,61 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint return (
- -
- {route.tab === 'dashboard' && } - {route.tab === 'requestTypes' && } - {route.tab === 'routes' && !route.routeId && ( - - )} - {route.tab === 'routes' && route.routeId && selectedRoute && ( - navigate({ tab: 'routes', routesFilter: route.routesFilter })} - /> - )} - {route.tab === 'resolve' && ( - - )} - {route.tab === 'manageRequests' && ( - - )} + +
+ navigateTab(id as ScreenKey)} + brand={texts.nav.title} + collapsed={navCollapsed} + onToggleCollapsed={() => setNavCollapsed(v => !v)} + mobileOpen={mobileNavOpen} + onMobileClose={() => setMobileNavOpen(false)} + />
+
+
+ setMobileNavOpen(true)} /> +
+
+ {route.tab === 'dashboard' && } + {route.tab === 'requestTypes' && } + {route.tab === 'routes' && !route.routeId && ( + + )} + {route.tab === 'routes' && route.routeId && selectedRoute && ( + navigate({ tab: 'routes', routesFilter: route.routesFilter })} + /> + )} + {route.tab === 'resolve' && ( + + )} + {route.tab === 'manageRequests' && ( + + )} + {route.tab === 'manageByUser' && ( + + )} +
+
); }; diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss b/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss deleted file mode 100644 index b5bf541..0000000 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss +++ /dev/null @@ -1,76 +0,0 @@ -@import '../../../../styles/design-tokens'; - -.topnav { - background: $grad-header; - padding: 0 22px; - display: flex; - align-items: center; - height: 54px; - box-shadow: 0 1px 0 rgba(212,160,30,.35); - border-radius: $r-md $r-md 0 0; - // Без переноса: высота шапки фиксирована (54px), поэтому перенесённая - // строка вкладок вылезала бы за её пределы вместе с подчёркиванием. - // Вместо переноса вкладки скроллятся по горизонтали. - flex-wrap: nowrap; - overflow: hidden; -} - -.brand { - display: flex; - align-items: center; - gap: 10px; - margin-right: 20px; - flex-shrink: 0; -} - -.brandText { - color: #fff; - font-weight: 600; - font-size: 13px; - letter-spacing: .4px; - white-space: nowrap; -} - -.tabs { - display: flex; - height: 100%; - flex-wrap: nowrap; - // min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего - // содержимого и overflow-x никогда не сработает. - min-width: 0; - overflow-x: auto; - overflow-y: hidden; - // Делает контейнер offsetParent'ом для вкладок: автопрокрутка в TopNav.tsx - // считает offsetLeft относительно него. Без этого offsetLeft отсчитывался - // бы от body и прокрутка уезжала бы не туда. - position: relative; - - // Тонкая полупрозрачная полоса — не спрятана полностью: с добавлением новых - // вкладок (сейчас их 6) они не всегда помещаются в узкую панель, и без - // видимого скроллбара непонятно, что часть вкладок обрезана, а не пропала. - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, .35) transparent; - &::-webkit-scrollbar { height: 4px; } - &::-webkit-scrollbar-track { background: transparent; } - &::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .35); border-radius: 2px; } -} - -.tab { - flex-shrink: 0; - white-space: nowrap; - padding: 0 13px; - border: none; - background: 0; - color: rgba(255,255,255,.5); - font-size: 13px; - font-weight: 500; - cursor: pointer; - font-family: inherit; - transition: .18s; - height: 54px; - border-bottom: 2px solid transparent; - letter-spacing: .2px; - - &:hover { color: rgba(255,255,255,.85); } - &.on { color: $gold-300; border-bottom-color: $gold-400; } -} diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx b/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx deleted file mode 100644 index 2a33140..0000000 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import * as React from 'react'; -import styles from './TopNav.module.scss'; -import { ScreenKey } from './types'; -import { texts } from '../../texts'; - -interface ITab { - key: ScreenKey; - label: string; -} - -const TABS: ITab[] = [ - { key: 'dashboard', label: texts.nav.dashboard }, - { key: 'requestTypes', label: texts.nav.requestTypes }, - { key: 'routes', label: texts.nav.routes }, - { key: 'resolve', label: texts.nav.resolve }, - { key: 'manageRequests', label: texts.nav.manageRequests } -]; - -export interface ITopNavProps { - active: ScreenKey; - onSelect: (key: ScreenKey) => void; -} - -function isTabActive(active: ScreenKey, tabKey: ScreenKey): boolean { - // Экран шагов маршрута — вложенный, подсвечиваем вкладку «Маршруты». - return active === tabKey || (active === 'routeSteps' && tabKey === 'routes'); -} - -export const TopNav: React.FC = ({ active, onSelect }) => { - const tabsRef = React.useRef(null); - const activeTabRef = React.useRef(null); - - // Вкладки скроллятся по горизонтали, поэтому активная может оказаться за - // краем. Подкручиваем контейнер вручную (не scrollIntoView — тот способен - // утащить за собой прокрутку всей страницы SharePoint). - React.useEffect(() => { - const reveal = (): void => { - const container = tabsRef.current; - const tab = activeTabRef.current; - if (!container || !tab) return; - - const left = tab.offsetLeft; - const width = tab.offsetWidth; - - if (width > container.clientWidth) { - // Вкладка шире видимой полосы — показываем её начало, чтобы подпись - // читалась с первого слова. - container.scrollLeft = left; - } else if (left < container.scrollLeft) { - container.scrollLeft = left; - } else if (left + width > container.scrollLeft + container.clientWidth) { - container.scrollLeft = left + width - container.clientWidth; - } - }; - - reveal(); - - // Шрифты (Lora/Inter) подключаются рантаймом: пока они не применились, - // ширины вкладок другие и позиция прокрутки получается устаревшей. - const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; - let cancelled = false; - if (fonts?.ready) { - fonts.ready.then(() => { if (!cancelled) reveal(); }).catch(() => { /* не критично */ }); - } - - window.addEventListener('resize', reveal); - return () => { - cancelled = true; - window.removeEventListener('resize', reveal); - }; - }, [active]); - - return ( - - ); -}; diff --git a/frontend/src/webparts/approvalAdmin/components/app/types.ts b/frontend/src/webparts/approvalAdmin/components/app/types.ts index d6b53d8..71b47cb 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/types.ts +++ b/frontend/src/webparts/approvalAdmin/components/app/types.ts @@ -1 +1 @@ -export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests'; +export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser'; 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/ManageByUserDuplicateModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserDuplicateModal.tsx new file mode 100644 index 0000000..5348bb6 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserDuplicateModal.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { texts } from '../../texts'; +import { Button, Modal } from '../ui'; + +export type DuplicateChoice = 'delete' | 'skip'; + +export interface IManageByUserDuplicateModalProps { + title: string; + message: string; + onChoice: (choice: DuplicateChoice, forAll: boolean) => void; +} + +/** + * «Да / Да для всех / Нет / Нет для всех» — классический диалог разрешения + * конфликтов при пакетной операции (как в проводнике при копировании файлов). + * Закрытие крестиком/подложкой равносильно «Нет» (без «для всех» — иначе + * случайный клик мимо тихо остановил бы весь оставшийся список). + */ +export const ManageByUserDuplicateModal: React.FC = ({ title, message, onChoice }) => ( + onChoice('skip', false)} + footer={ + <> + + + + + + } + > +

{message}

+
+); diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss new file mode 100644 index 0000000..869079b --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss @@ -0,0 +1,36 @@ +@import '../../../../styles/design-tokens'; + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.panelTitle { + font-size: 13px; + font-weight: 600; + color: $gray-700; +} + +.panelActions { + display: flex; + 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 new file mode 100644 index 0000000..2dadb71 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx @@ -0,0 +1,589 @@ +import * as React from 'react'; +import styles from './ManageByUserScreen.module.scss'; +import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal'; +import { ApprovalRouteApi } from '../../services/api'; +import { PortalDictionaryClient } from '../../services/portalDictionary'; +import { RequestManageService } from '../../services/requestManageService'; +import { ApiClientError } from '../../services/httpClient'; +import { REQUEST_STATUS, RequestListPartition, UserApprovalRow, UserTemplateStepRow } from '../../models'; +import { routeStepStatusLabels, texts } from '../../texts'; +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'; +import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll'; +import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor'; +import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup'; +import { usePortalEmployees } from '../../hooks/usePortalEmployees'; +import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; +import { + ActionsCell, + Alert, + Badge, + BadgeVariant, + Breadcrumbs, + Button, + Checkbox, + DataTable, + Field, + FormGrid, + IColumn, + IconButton, + PageHeader, + Panel, + PrimaryCell, + ProgressOverlay, + SearchableSelect, + Toast +} from '../ui'; + +export interface IManageByUserScreenProps { + api: ApprovalRouteApi; + /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ + dictionary?: PortalDictionaryClient; + requestManage: RequestManageService; + /** Заполнение из URL (глубокая ссылка на конкретного сотрудника) — см. routing.ts. */ + route?: IManageByUserRouteParams; +} + +// Выбор (кого меняем и на кого) переживает перезагрузку страницы — как и выбор +// заявки в ManageRequestsScreen (тот же приём, отдельный ключ). +const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserSelection'; + +interface IPersistedSelection { + employeeEmail: string; + targetEmail: string; +} + +const EMPTY_SELECTION: IPersistedSelection = { employeeEmail: '', targetEmail: '' }; + +// Активные статусы строки для этого экрана — сознательно уже, чем +// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и +// черновики сюда не входят (согласовано отдельно, см. план реализации). +const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution]; + +// Итог пакетной операции по одной панели. +interface IBulkOutcome { + ok: number; + fail: number; + /** Дубли, снятые удалением старой строки/шага (пользователь выбрал «удалить»). */ + deleted: number; + /** Дубли, оставленные как есть (пользователь выбрал «пропустить»). */ + skipped: number; +} + +// Та же цветовая группировка статуса заявки, что и в ManageRequestsScreen +// (дублирование небольшое и не стоит выносить ради одной строки). +function statusVariant(status: string): BadgeVariant { + if (status === REQUEST_STATUS.onExecution) return 'blue'; + if (status === REQUEST_STATUS.queued) return 'neutral'; + return 'gold'; +} + +function liveRowKey(r: UserApprovalRow): string { + return `${r.listId}:${r.id}`; +} + +interface IReplaceScope { + live: UserApprovalRow[]; + templates: UserTemplateStepRow[]; +} + +export const ManageByUserScreen: React.FC = ({ api, dictionary, requestManage, route }) => { + const requestTypes = useRequestTypes(api); + const listSeries = useRequestTypeListsAll(api); + const employees = usePortalEmployees(dictionary); + const dictionaries = usePortalDictionaries(dictionary); + + const [selection, setSelection] = useLocalStorageState(SELECTION_STORAGE_KEY, EMPTY_SELECTION); + const { employeeEmail, targetEmail } = selection; + + 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) setSelection({ employeeEmail: route.userEmail, targetEmail: route.targetEmail }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Держим URL синхронным с выбором — открытый экран можно скопировать и + // прислать коллеге. replaceRoute, а не navigate: смена сотрудника — правка + // состояния текущего экрана, а не отдельный шаг в истории браузера. + React.useEffect(() => { + replaceRoute({ + tab: 'manageByUser', + manageByUser: employeeEmail ? { userEmail: employeeEmail, targetEmail } : undefined + }); + }, [employeeEmail, targetEmail]); + + const partitionsByType = React.useMemo(() => { + const map = new Map(); + listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions)); + return map; + }, [listSeries.items]); + + const [liveRows, setLiveRows] = React.useState([]); + const [liveLoading, setLiveLoading] = React.useState(false); + const [liveError, setLiveError] = React.useState(); + const [checkedLiveKeys, setCheckedLiveKeys] = React.useState>(new Set()); + + const reloadLiveRows = React.useCallback(() => { + if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return; + setLiveLoading(true); + setLiveError(undefined); + requestManage + .findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES) + .then(setLiveRows) + .catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover'))) + .finally(() => setLiveLoading(false)); + }, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]); + + React.useEffect(() => { + setLiveRows([]); + setCheckedLiveKeys(new Set()); + reloadLiveRows(); + }, [reloadLiveRows]); + + const templateSteps = useTemplateRouteStepsByExecutor(api, employeeEmail || undefined); + const routeIds = React.useMemo(() => templateSteps.items.map(s => s.routeId), [templateSteps.items]); + const routesLookup = useTemplateRoutesLookup(api, routeIds); + const [checkedTemplateIds, setCheckedTemplateIds] = React.useState>(new Set()); + + const templateRows: UserTemplateStepRow[] = React.useMemo( + () => + templateSteps.items.map(step => { + const route = routesLookup.byId.get(step.routeId); + const requestType = route ? requestTypes.items.filter(rt => rt.id === route.requestTypeId)[0] : undefined; + const companyName = route?.companyId + ? dictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId) + : texts.routeSteps.anyCompany; + const departmentName = route?.departmentId + ? dictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId) + : texts.routeSteps.anyDepartment; + return { + ...step, + requestTypeName: requestType?.name ?? (route ? `#${route.requestTypeId}` : texts.common.empty), + companyName, + departmentName + }; + }), + [templateSteps.items, routesLookup.byId, requestTypes.items, dictionaries.companies, dictionaries.departments] + ); + + const toggleLive = (row: UserApprovalRow): void => + setCheckedLiveKeys(prev => { + const next = new Set(prev); + const key = liveRowKey(row); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const toggleTemplate = (row: UserTemplateStepRow): void => + setCheckedTemplateIds(prev => { + const next = new Set(prev); + if (next.has(row.id)) next.delete(row.id); + else next.add(row.id); + return next; + }); + + const checkedLiveRows = liveRows.filter(r => checkedLiveKeys.has(liveRowKey(r))); + const checkedTemplateRows = templateRows.filter(r => checkedTemplateIds.has(r.id)); + + const allLiveChecked = liveRows.length > 0 && checkedLiveRows.length === liveRows.length; + const toggleAllLive = (): void => setCheckedLiveKeys(allLiveChecked ? new Set() : new Set(liveRows.map(liveRowKey))); + + const allTemplatesChecked = templateRows.length > 0 && checkedTemplateRows.length === templateRows.length; + const toggleAllTemplates = (): void => + setCheckedTemplateIds(allTemplatesChecked ? new Set() : new Set(templateRows.map(r => r.id))); + + const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>(); + // Пока задан — интерфейс заблокирован подложкой с полосой прогресса + // (ProgressOverlay), и запустить вторую замену поверх первой нельзя. + const [progress, setProgress] = React.useState<{ done: number; total: number } | undefined>(); + + // Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл + // замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно + // из-за этого диалога: параллельные ветки открыли бы N окон разом. + const [duplicatePrompt, setDuplicatePrompt] = + React.useState<{ message: string; resolve: (choice: DuplicateChoice) => void } | undefined>(); + // «…для всех» на время ОДНОЙ пакетной операции — ref, а не state: значение + // читается внутри уже запущенного цикла, и перерисовка ему не нужна. + 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 })); + }; + + const answerDuplicate = (choice: DuplicateChoice, forAll: boolean): void => { + if (forAll) duplicatePolicy.current = choice; + const pending = duplicatePrompt; + setDuplicatePrompt(undefined); + pending?.resolve(choice); + }; + + /** + * Замена в живых заявках — тремя фазами, чтобы совместить параллельность с + * интерактивным разрешением дублей: + * + * 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 }; + + // Фаза 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): своей предварительной проверки не делаем, + * его правило шире email'а (совпадать должны ещё статус и условие появления), + * и дублировать это на клиенте значило бы разъехаться с ним при первой же + * правке. Поэтому «проверка» и есть сам PATCH — он идёт параллельно, а + * разбираем 409-ые уже после. + */ + async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise { + const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 }; + + 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; + 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 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 }); + + 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; + } + + setResultToast({ message: messages.join(' '), kind: 'ok' }); + setCheckedLiveKeys(new Set()); + setCheckedTemplateIds(new Set()); + reloadLiveRows(); + 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 }, + { + key: 'request', + header: texts.manageByUser.columnRequest, + render: r => {`${r.requestUniqueCode || `#${r.ticketId}`} ${r.requestTitle}`} + }, + { key: 'position', header: texts.manageByUser.columnPosition, render: r => r.position || texts.common.empty }, + { key: 'status', header: texts.manageByUser.columnStatus, render: r => {r.status} }, + { key: 'phase', header: texts.manageByUser.columnPhase, render: r => r.phase }, + { + key: 'actions', + header: '', + sticky: true, + render: r => ( + + runReplace({ live: [r], templates: [] })} /> + + ) + } + ]; + + const templateColumns: IColumn[] = [ + { key: 'check', header: '', width: '32px', render: r => toggleTemplate(r)} /> }, + { + key: 'route', + header: texts.manageByUser.columnRoute, + render: r => {`${r.requestTypeName} — ${r.companyName} / ${r.departmentName}`} + }, + { key: 'stepStatus', header: texts.manageByUser.columnStepStatus, render: r => routeStepStatusLabels[r.status] }, + { key: 'condition', header: texts.manageByUser.columnCondition, render: r => describeStepCondition(r) ?? texts.common.empty }, + { + key: 'actions', + header: '', + sticky: true, + render: r => ( + + runReplace({ live: [], templates: [r] })} /> + + ) + } + ]; + + const hasAnyRows = liveRows.length > 0 || templateRows.length > 0; + + return ( +
+ + runReplace({ live: liveRows, templates: templateRows })} + > + {texts.manageByUser.replaceEverywhere} + + } + /> + + + + + + + {/* Кого ставим вместо — рядом, а не в модалке по кнопке: замена обычно + идёт в несколько приёмов (сначала заявки, потом маршруты, потом + отдельные строки), и выбирать одного и того же человека заново + на каждый приём незачем. */} + +
+
+ +
+ {/* Обратная замена — обычное продолжение работы («вернуть всё + как было», «а теперь наоборот»), и переставлять двух человек + руками через два поиска по справочнику ради этого не нужно. */} + +
+
+
+
+ + {employeeEmail && ( + +
+
{`${texts.manageByUser.liveTitle} (${liveRows.length})`}
+
+ + +
+
+ {liveError && {`${texts.manageByUser.liveLoadError}: ${liveError}`}} + {liveLoading &&
{texts.common.loading}
} + {!liveLoading && !liveError && ( + + )} +
+ )} + + {employeeEmail && ( + +
+
{`${texts.manageByUser.templatesTitle} (${templateRows.length})`}
+
+ + +
+
+ {templateSteps.error && {`${texts.manageByUser.templatesLoadError}: ${templateSteps.error}`}} + {templateSteps.isLoading &&
{texts.common.loading}
} + {!templateSteps.isLoading && !templateSteps.error && ( + r.id} emptyMessage={texts.manageByUser.templatesEmpty} /> + )} +
+ )} + + {progress && ( + + )} + + {duplicatePrompt && ( + + )} + + {resultToast && ( + setResultToast(undefined)} /> + )} +
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx index e14cb84..6ea90d0 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -15,6 +15,7 @@ import { } from '../../models'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; +import { useRequestTypeListPartitions } from '../../hooks/useRequestTypeListPartitions'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { findEmployeeByEmail } from '../../utils/employee'; @@ -47,6 +48,14 @@ export interface IManageRequestsScreenProps { route?: IManageRequestRouteParams; } +// Подпись заявки в комбобоксе — уникальный код (RequestUniqueCode, тот же, что +// видит заявитель на портале), а не служебный числовой Id списка SharePoint. +// Запасной вариант на #id — для заявок без заполненного кода (старые заявки, +// либо список данных этой колонки не заводит). +function requestLabel(r: Pick): string { + return `${r.uniqueCode || `#${r.id}`} ${r.title}`; +} + // Цвет плашки статуса — те же группы смысла, что и в request-portal // (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик. function statusVariant(status: string): BadgeVariant { @@ -158,7 +167,19 @@ export const ManageRequestsScreen: React.FC = ({ api if (!route) return; setSelection({ requestTypeId: route.requestTypeId, - selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' } + // listId из ссылки — какая партиция, если ссылку скопировали уже после + // этого фикса; пусто у старых ссылок — тогда reloadMatrix ниже + // подставит текущую партицию (тот же эффект, что был раньше, но теперь + // явный, а не единственно возможный). + selectedRequest: { + id: Number(route.requestId), + title: '', + status: '', + created: '', + authorName: '', + uniqueCode: null, + listId: route.listId + } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -167,11 +188,15 @@ export const ManageRequestsScreen: React.FC = ({ api // прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка // состояния текущего экрана, а не переход на новую страницу с отдельным // шагом в истории браузера. title/status заявки в URL не пишем — их выводит - // сам requestId, хранить производные данные в ссылке незачем. + // сам requestId, хранить производные данные в ссылке незачем. listId — + // пишем: без него по ссылке нельзя однозначно понять, в какой партиции + // искать requestId (см. IManageRequestRouteParams). React.useEffect(() => { replaceRoute({ tab: 'manageRequests', - manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined + manage: selectedRequest + ? { requestTypeId, requestId: String(selectedRequest.id), listId: selectedRequest.listId } + : undefined }); }, [requestTypeId, selectedRequest]); const [matrix, setMatrix] = React.useState(); @@ -192,6 +217,11 @@ export const ManageRequestsScreen: React.FC = ({ api // и читать заявки этого типа отсюда просто негде. const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl); + // Вся серия партиций выбранного типа (архив + текущая) — заявки этого типа + // физически лежат в любой из них (см. ТЗ по архивации), искать и открывать + // нужно по всем сразу, не только по текущей. + const partitions = useRequestTypeListPartitions(api, requestType?.id); + const selectRequestType = (id: string): void => { // Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит // показывать заявку не того типа, что выбран сейчас. @@ -205,10 +235,12 @@ export const ManageRequestsScreen: React.FC = ({ api const searchRequests = React.useCallback( (query: string, page: number) => { - if (!requestType) return Promise.resolve({ items: [], hasMore: false }); - return requestManage.searchRequests(requestType, query, page); + if (!requestType || !requestType.siteUrl || partitions.items.length === 0) { + return Promise.resolve({ items: [], hasMore: false }); + } + return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page); }, - [requestManage, requestType] + [requestManage, requestType, partitions.items] ); // silent=true — фоновое обновление (после своего же действия, автообновление @@ -219,18 +251,38 @@ export const ManageRequestsScreen: React.FC = ({ api const reloadMatrix = React.useCallback( (silent = false) => { if (!requestType || !selectedRequest) return; + // Партиции ещё не подтянулись (первый рендер после смены типа/захода по + // ссылке) — без них нельзя даже подставить запасной вариант (текущая + // партиция) для ссылок без listId, ждём следующего рендера. + if (partitions.isLoading) return; + if (!requestType.siteUrl) { + setMatrixError('У типа заявки не указан список SharePoint.'); + return; + } + // listId заявки уже известен (выбрана из комбобокса, либо ссылка после + // этого фикса) — используем его. Иначе (старая ссылка без listId) — + // единственный разумный запасной вариант: текущая партиция, тот же + // список, что искался бы и раньше. + const listId = selectedRequest.listId || partitions.items.filter(p => p.isCurrent)[0]?.listId; + if (!listId) { + setMatrixError('Для типа заявки не найдено ни одной партиции списка SharePoint.'); + return; + } if (!silent) setMatrixLoading(true); setMatrixError(undefined); requestManage - .getMatrix(requestType, selectedRequest.id) + .getMatrix(requestType.siteUrl, listId, selectedRequest.id) .then(m => { setMatrix(m); - // title/status в комбобоксе (а значит, и в localStorage) держим + // title/status/listId в комбобоксе (а значит, и в localStorage) держим // синхронными с сервером — особенно важно после захода по глубокой - // ссылке, где изначально они пустые (см. эффект-затравку выше). + // ссылке, где изначально они пустые (см. эффект-затравку выше), и + // после подстановки запасной (текущей) партиции выше: как только + // реальная партиция заявки известна, оседает в выборе — следующий + // reloadMatrix пойдёт уже по ней напрямую. // - // ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не - // отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг + // ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если ничего не + // отличается — m.request это каждый раз НОВЫЙ объект (свежий парсинг // ответа), и если писать его в состояние безусловно, selectedRequest // получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не // поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму @@ -240,7 +292,7 @@ export const ManageRequestsScreen: React.FC = ({ api setSelection(prev => { const sel = prev.selectedRequest; if (!sel || sel.id !== m.request.id) return prev; - const same = sel.title === m.request.title && sel.status === m.request.status; + const same = sel.title === m.request.title && sel.status === m.request.status && sel.listId === m.request.listId; return same ? prev : { ...prev, selectedRequest: m.request }; }); }) @@ -249,7 +301,7 @@ export const ManageRequestsScreen: React.FC = ({ api if (!silent) setMatrixLoading(false); }); }, - [requestManage, requestType, selectedRequest] + [requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading] ); React.useEffect(() => { @@ -533,14 +585,14 @@ export const ManageRequestsScreen: React.FC = ({ api key={requestTypeId || 'none'} value={selectedRequest ? String(selectedRequest.id) : ''} - selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined} + selectedLabel={selectedRequest ? requestLabel(selectedRequest) : undefined} selectedSublabel={selectedRequest?.status} disabled={!requestType} placeholder={texts.manageRequests.requestPlaceholder} searchPlaceholder={texts.manageRequests.requestSearchPlaceholder} search={searchRequests} getValue={r => String(r.id)} - getLabel={r => `#${r.id} ${r.title}`} + getLabel={requestLabel} getSublabel={r => r.status} onChange={(_, item) => selectRequest(item)} /> 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/hooks/useRequestTypeListPartitions.ts b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListPartitions.ts new file mode 100644 index 0000000..781dd4f --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListPartitions.ts @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { RequestListPartition } from '../models'; +import { ApprovalRouteApi } from '../services/api'; +import { AsyncList, useAsyncList } from './useAsyncList'; + +/** + * Серия партиций (архив + текущая) ОДНОГО типа заявки — нужна экрану + * редактирования заявок: заявка может лежать в любой из них, не только в + * текущей (см. requestManageService.ts). + */ +export function useRequestTypeListPartitions( + api: ApprovalRouteApi, + requestTypeId: number | undefined +): AsyncList { + const load = React.useCallback( + () => (requestTypeId ? api.requestTypes.lists(requestTypeId) : Promise.resolve([])), + [api, requestTypeId] + ); + return useAsyncList(load); +} diff --git a/frontend/src/webparts/approvalAdmin/hooks/useTemplateRouteStepsByExecutor.ts b/frontend/src/webparts/approvalAdmin/hooks/useTemplateRouteStepsByExecutor.ts new file mode 100644 index 0000000..25d6c3c --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useTemplateRouteStepsByExecutor.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { TemplateRouteStep } from '../models'; +import { ApprovalRouteApi } from '../services/api'; +import { AsyncList, useAsyncList } from './useAsyncList'; + +/** Шаги ВСЕХ маршрутов с данным ExecutorEmail — для экрана «Заявки по согласующему». */ +export function useTemplateRouteStepsByExecutor( + api: ApprovalRouteApi, + executorEmail: string | undefined +): AsyncList { + const load = React.useCallback( + () => (executorEmail ? api.templateRouteSteps.listByExecutorEmail(executorEmail) : Promise.resolve([])), + [api, executorEmail] + ); + return useAsyncList(load); +} diff --git a/frontend/src/webparts/approvalAdmin/hooks/useTemplateRoutesLookup.ts b/frontend/src/webparts/approvalAdmin/hooks/useTemplateRoutesLookup.ts new file mode 100644 index 0000000..261f9b6 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useTemplateRoutesLookup.ts @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { TemplateRoute } from '../models'; +import { ApprovalRouteApi } from '../services/api'; + +export interface TemplateRoutesLookup { + byId: Map; + isLoading: boolean; + error?: string; +} + +/** + * Дочитывает TemplateRoute по списку id (например, найденных через + * useTemplateRouteStepsByExecutor) — bulk-эндпоинта нет, поэтому по одному + * запросу на маршрут параллельно. Нужен для отображения (тип заявки/компания/ + * отдел маршрута), сам TemplateRouteStepDto хранит только routeId. + */ +export function useTemplateRoutesLookup(api: ApprovalRouteApi, routeIds: number[]): TemplateRoutesLookup { + const [byId, setById] = React.useState>(new Map()); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(); + + // Стабильный ключ вместо самого массива routeIds — иначе (новая ссылка на + // массив с тем же содержимым при каждом рендере вызывающего экрана) эффект + // перезапускался бы бесконечно. + const key = React.useMemo(() => Array.from(new Set(routeIds)).sort((a, b) => a - b).join(','), [routeIds]); + + React.useEffect(() => { + const ids = key ? key.split(',').map(Number) : []; + if (ids.length === 0) { + setById(new Map()); + return; + } + + let alive = true; + setIsLoading(true); + setError(undefined); + Promise.all(ids.map(id => api.templateRoutes.get(id))) + .then(routes => { + if (!alive) return; + setById(new Map(routes.map(r => [r.id, r]))); + }) + .catch((e: Error) => { + if (alive) setError(e.message); + }) + .finally(() => { + if (alive) setIsLoading(false); + }); + return () => { + alive = false; + }; + }, [api, key]); + + return { byId, isLoading, error }; +} diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index c800bf9..2a19b8d 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -7,3 +7,4 @@ export * from './templateRoutes'; export * from './templateRouteSteps'; export * from './resolve'; export * from './requestManage'; +export * from './manageByUser'; diff --git a/frontend/src/webparts/approvalAdmin/models/manageByUser.ts b/frontend/src/webparts/approvalAdmin/models/manageByUser.ts new file mode 100644 index 0000000..6bb6f31 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/models/manageByUser.ts @@ -0,0 +1,31 @@ +// Домен вкладки «Заявки по согласующему» (ManageByUserScreen) — сводит вместе +// два независимых источника по одному конкретному человеку: живые строки +// согласования (SharePoint, requestManageService.ts) и шаблонные шаги +// маршрутов (Tnsu.ApprovalRoute.Api, TemplateRouteStep с ExecutorEmail). +import { ManagedApprovalRow } from './requestManage'; +import { TemplateRouteStep } from './templateRouteSteps'; + +/** + * Строка согласования, найденная сразу по ВСЕМ типам заявок и ВСЕМ их + * партициям — в отличие от ManagedApprovalRow (одна открытая заявка одного + * типа), здесь нужен контекст, к какому типу/списку/партиции она относится, + * чтобы её можно было и показать, и записать обратно через reassignApprover. + */ +export interface UserApprovalRow extends ManagedApprovalRow { + requestTypeId: number; + requestTypeName: string; + siteUrl: string; + /** Партиция списка ДАННЫХ, где физически лежит заявка. */ + listId: string; + approvalListTitle: string; + requestTitle: string; + // eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть + requestUniqueCode: string | null; +} + +/** Шаг шаблона маршрута с ExecutorEmail = искомый человек, обогащённый для отображения. */ +export interface UserTemplateStepRow extends TemplateRouteStep { + requestTypeName: string; + companyName: string; + departmentName: string; +} diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.ts index 19f9078..b9e9207 100644 --- a/frontend/src/webparts/approvalAdmin/models/requestManage.ts +++ b/frontend/src/webparts/approvalAdmin/models/requestManage.ts @@ -78,6 +78,21 @@ export interface ManagedRequestSummary { status: string; created: string; // ISO authorName: string; + /** + * Уникальный код заявки (колонка RequestUniqueCode списка данных) — то, что + * реально видит заявитель, в отличие от служебного числового Id списка + * SharePoint. null — у заявки его нет (старая заявка, колонка не заполнена) + * или список данных этой колонки не заводит; тогда в UI показывается id. + */ + // eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть + uniqueCode: string | null; + /** + * Список-партиция, в котором физически лежит эта заявка. У разных партиций + * независимая нумерация Id (см. ТЗ по архивации), поэтому один и тот же + * requestId в разных партициях — это РАЗНЫЕ заявки: без listId открыть + * заявку однозначно нельзя. + */ + listId: string; } export interface ManagedRequestPage { diff --git a/frontend/src/webparts/approvalAdmin/routing.test.ts b/frontend/src/webparts/approvalAdmin/routing.test.ts index 5246dae..7935db7 100644 --- a/frontend/src/webparts/approvalAdmin/routing.test.ts +++ b/frontend/src/webparts/approvalAdmin/routing.test.ts @@ -9,7 +9,13 @@ beforeEach(() => { describe('parseRoute', () => { it('без aatab — вкладка по умолчанию dashboard, всё остальное пусто', () => { - expect(parseRoute('')).toEqual({ tab: 'dashboard', routeId: undefined, resolve: undefined, manage: undefined }); + expect(parseRoute('')).toEqual({ + tab: 'dashboard', + routeId: undefined, + resolve: undefined, + manage: undefined, + manageByUser: undefined + }); }); it('незнакомое значение aatab тоже откатывается на dashboard', () => { @@ -19,7 +25,12 @@ describe('parseRoute', () => { it('manageRequests с aamtype+aamreq — даёт заполненный manage', () => { const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42'); expect(route.tab).toBe('manageRequests'); - expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42' }); + expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42', listId: '' }); + }); + + it('manageRequests с aamlist — партиция заявки читается в manage.listId', () => { + const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42&aamlist=%2Fsites%2Fx%2FLists%2FForm2'); + expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42', listId: '/sites/x/Lists/Form2' }); }); it('manageRequests без aamreq — manage не собирается (id заявки обязателен)', () => { @@ -27,6 +38,25 @@ describe('parseRoute', () => { expect(route.manage).toBeUndefined(); }); + 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', 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', () => { + expect(parseRoute('?aatab=dashboard&aabuser=ivanov%40tnsu.kz').manageByUser).toBeUndefined(); + }); + it('aaroute учитывается только при tab=routes', () => { expect(parseRoute('?aatab=routes&aaroute=7').routeId).toBe(7); expect(parseRoute('?aatab=manageRequests&aaroute=7').routeId).toBeUndefined(); @@ -78,14 +108,14 @@ describe('parseRoute', () => { describe('navigate', () => { it('после navigate() параметры читаются обратно тем же parseRoute (round-trip)', () => { - navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } }); + navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' } }); const roundTripped = parseRoute(window.location.search); expect(roundTripped.tab).toBe('manageRequests'); - expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10' }); + expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' }); }); it('navigate на dashboard полностью убирает параметры вкладки из адреса', () => { - navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } }); + navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' } }); navigate({ tab: 'dashboard' }); expect(window.location.search).toBe(''); }); @@ -96,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 3e99d88..dd0d996 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -33,9 +33,29 @@ export interface IResolveRouteParams { departmentId: string; } +export interface IManageByUserRouteParams { + /** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */ + userEmail: string; + /** + * На кого заменять. Часть ссылки, а не только состояние экрана: замена обычно + * идёт в несколько приёмов (сначала заявки, потом маршруты, потом что-то + * отдельной строкой), и выбранного «получателя» незачем терять между ними. + * Пусто — получатель ещё не выбран. + */ + targetEmail: string; +} + export interface IManageRequestRouteParams { requestTypeId: string; requestId: string; + /** + * Партиция (список-архив данных), где физически лежит заявка — у разных + * партиций независимая нумерация Id (см. ТЗ по архивации), поэтому без + * listId requestId неоднозначен. Пусто — старая ссылка (до архивации) или + * ссылка ещё не резолвнута; тогда экран считает, что заявка в текущей + * партиции (см. ManageRequestsScreen). + */ + listId: string; } export interface IAdminRoute { @@ -53,18 +73,21 @@ export interface IAdminRoute { resolve?: IResolveRouteParams; /** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */ manage?: IManageRequestRouteParams; + /** Задан только при tab='manageByUser' и непустом userEmail. */ + manageByUser?: IManageByUserRouteParams; } const ROUTE_CHANGE_EVENT = 'aa-route-change'; // 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а // состояние вкладки 'routes' при наличии aaroute (см. parseRoute). -const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests']; +const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests', 'manageByUser']; const PARAM_KEYS = [ 'aatab', 'aaroute', 'aarfilter', 'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept', - 'aamtype', 'aamreq' + 'aamtype', 'aamreq', 'aamlist', + 'aabuser', 'aabtarget' ]; // raw — результат URLSearchParams.get() (DOM API, возвращает string | null). @@ -114,15 +137,20 @@ export function parseRoute(search: string = window.location.search): IAdminRoute const manageReqId = params.get('aamreq'); const manage: IManageRequestRouteParams | undefined = tab === 'manageRequests' && manageTypeId && manageReqId - ? { requestTypeId: manageTypeId, requestId: manageReqId } + ? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' } : undefined; + const byUserEmail = tab === 'manageByUser' ? params.get('aabuser') : null; + return { tab, routeId: routeId && !isNaN(routeId) ? routeId : undefined, routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined, resolve, - manage + manage, + manageByUser: byUserEmail + ? { userEmail: byUserEmail, targetEmail: params.get('aabtarget') || '' } + : undefined }; } @@ -152,6 +180,12 @@ function buildQuery(route: IAdminRoute): string { if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) { params.set('aamtype', route.manage.requestTypeId); params.set('aamreq', route.manage.requestId); + if (route.manage.listId) params.set('aamlist', route.manage.listId); + } + + 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/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index adc80bc..ad837d2 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -6,6 +6,7 @@ import { PatchRequestTypeInput, PatchTemplateRouteInput, PatchTemplateRouteStepInput, + RequestListPartition, RequestType, RequestTypeListSeries, ResolveTemplateRouteQuery, @@ -47,7 +48,13 @@ export class ApprovalRouteApi { // портала, которому нужен весь список адресов разом, а не по одному запросу // на тип. listsAll: (): Promise => - this.http.get('/api/request-types/lists') + this.http.get('/api/request-types/lists'), + + // Вся серия партиций ОДНОГО типа заявки (архив + текущая) — нужна экрану + // редактирования заявок: заявка может лежать в любой из них, не только в + // текущей (см. requestManageService.ts). + lists: (requestTypeId: number): Promise => + this.http.get(`/api/request-types/${requestTypeId}/lists`) }; public readonly templateRoutes = { @@ -92,6 +99,12 @@ export class ApprovalRouteApi { this.http.patch(`/api/template-routes/${routeId}/steps/${id}`, body), remove: (routeId: number, id: number): Promise => - this.http.delete(`/api/template-routes/${routeId}/steps/${id}`) + this.http.delete(`/api/template-routes/${routeId}/steps/${id}`), + + // Сквозной поиск ПО ВСЕМ маршрутам разом (не вложен под конкретный routeId) — + // для экрана «Заявки по согласующему» (ManageByUserScreen): шаги, где + // исполнитель задан этим конкретным email, а не должностью/ролью. + listByExecutorEmail: (executorEmail: string): Promise => + this.http.get('/api/template-route-steps', { executorEmail }) }; } diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts index 47f6b42..cd183d6 100644 --- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts +++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts @@ -4,6 +4,10 @@ import '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import '@pnp/sp/search'; +// sp.batched() — findActiveApprovalRowsForApprover читает все партиции ОДНОГО +// сайта одним HTTP-запросом (см. метод ниже). Импорт side-effect'ный: без него +// метод есть в типах, но в рантайме не зарегистрирован. +import '@pnp/sp/batching'; import { AadHttpClient } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { @@ -12,7 +16,9 @@ import { ManagedRequestMatrix, ManagedRequestPage, ManagedRequestSummary, - RequestType + RequestListPartition, + RequestType, + UserApprovalRow } from '../models'; // Список данных «<Форма> — данные» → список согласования «<Форма> — согласование» @@ -86,15 +92,19 @@ interface IRawDataRow { RequestStatus?: string; Created?: string; Author?: { Title?: string }; + // eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках + RequestUniqueCode?: string | null; } -function toSummary(r: IRawDataRow): ManagedRequestSummary { +function toSummary(r: IRawDataRow, listId: string): ManagedRequestSummary { return { id: r.Id, title: r.Title || `#${r.Id}`, status: r.RequestStatus || '', created: r.Created || '', - authorName: r.Author?.Title || '' + authorName: r.Author?.Title || '', + uniqueCode: r.RequestUniqueCode || null, + listId }; } @@ -124,24 +134,41 @@ export class RequestManageService { return { dataListTitle: info.Title, approvalListTitle }; } + // К какой партиции относится хит Search — по префиксу его Path. Партиции + // определяются вызывающей стороной (ListPartitionService.ListAsync), их + // список короткий (единицы), линейный поиск тут не проблема. + private partitionForPath( + siteUrl: string, + partitions: RequestListPartition[], + path: string + ): RequestListPartition | undefined { + return partitions.filter(p => path.indexOf(listAbsUrl(siteUrl, p.listId)) === 0)[0]; + } + /** - * Заявки типа requestType постранично, для комбобокса выбора заявки. Сначала - * Search по названию (свободный терм, пустой query — просто последние без - * фильтра), затем дочитывание полей REST'ом по найденным Id — Search не - * отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в - * request-portal). + * Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая) + * постранично, для комбобокса выбора заявки. Сначала Search по названию + * (свободный терм, пустой query — просто последние без фильтра), затем + * дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные + * поля списка надёжно (та же двухшаговая схема, что в request-portal). + * + * Заявки, набранные ростом одного типа, физически «переезжают» на новый + * список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе + * заявки из архивных партиций в комбобоксе просто не найти. */ public async searchRequests( - requestType: RequestType, + partitions: RequestListPartition[], + siteUrl: string, query: string, page: number, pageSize = 20 ): Promise { - if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false }; + if (partitions.length === 0) return { items: [], hasMore: false }; - const sp = this.clientFor(requestType.siteUrl); - const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId); - const clauses = [`Path:"${absUrl}*"`]; + const sp = this.clientFor(siteUrl); + const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`); + const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0]; + const clauses = [pathClause]; const text = freeTextClause(query); if (text) clauses.push(text); @@ -154,65 +181,83 @@ export class RequestManageService { SortList: [{ Property: 'Created', Direction: 1 }] }); - const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>; - // Дедуп по Id обязателен: TrimDuplicates: false выше выключает штатную - // дедупликацию Search (она иногда схлопывает нужные нам заявки), и тогда - // индекс может отдать один и тот же элемент несколькими хитами - // (переиндексация, несколько «версий» в crawl). Без этого фильтра хиты - // превращались в пункты комбобокса один-в-один, и заявка показывалась в - // списке дважды — с одинаковым React-ключом. Та же причина и то же лечение, - // что в request-portal (features/myRequests/myRequests.service.ts/hitsToEntries). - const seen = new Set(); - const ids = rawRows - .map(r => Number(r.ListItemID)) - .filter(id => !isNaN(id) && id > 0) - .filter(id => { - if (seen.has(id)) return false; - seen.add(id); - return true; - }); - if (ids.length === 0) return { items: [], hasMore: false }; + const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>; + // Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных + // партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому + // одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates: + // false выше выключает штатную дедупликацию Search (она иногда схлопывает + // нужные нам заявки), и тогда индекс может отдать один и тот же элемент + // несколькими хитами (переиндексация, несколько «версий» в crawl) — та же + // причина и то же лечение, что в request-portal + // (features/myRequests/myRequests.service.ts/hitsToEntries). + const seen = new Set(); + const hits: Array<{ id: number; partition: RequestListPartition }> = []; + rawRows.forEach(r => { + const id = Number(r.ListItemID); + if (isNaN(id) || id <= 0 || !r.Path) return; + const partition = this.partitionForPath(siteUrl, partitions, r.Path); + if (!partition) return; + const key = `${partition.listId}|${id}`; + if (seen.has(key)) return; + seen.add(key); + hits.push({ id, partition }); + }); + if (hits.length === 0) return { items: [], hasMore: false }; - const list = sp.web.getList(requestType.listId); - const dataRows = (await list.items - .filter(ids.map(id => `Id eq ${id}`).join(' or ')) - .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title') - .expand('Author') - .top(ids.length)()) as IRawDataRow[]; + const idsByListId = new Map(); + hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id])); + + const rowByKey = new Map(); + await Promise.all( + Array.from(idsByListId.entries()).map(async ([listId, ids]) => { + const list = sp.web.getList(listId); + const dataRows = (await list.items + .filter(ids.map(id => `Id eq ${id}`).join(' or ')) + .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode') + .expand('Author') + .top(ids.length)()) as IRawDataRow[]; + dataRows.forEach(r => rowByKey.set(`${listId}|${r.Id}`, r)); + }) + ); - const byId = new Map(dataRows.map(r => [r.Id, r])); // Порядок задаёт Search (SortList по дате), не порядок ответа REST. - const items = ids - .map(id => byId.get(id)) - .filter((r): r is IRawDataRow => !!r) - .map(toSummary); + const items = hits + .map(h => { + const row = rowByKey.get(`${h.partition.listId}|${h.id}`); + return row ? toSummary(row, h.partition.listId) : undefined; + }) + .filter((r): r is ManagedRequestSummary => !!r); const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length; return { items, hasMore: page * pageSize < totalRows }; } - /** Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. */ - public async getMatrix(requestType: RequestType, requestId: number): Promise { - if (!requestType.listId || !requestType.siteUrl) { - throw new Error('У типа заявки не указан список SharePoint.'); - } - const sp = this.clientFor(requestType.siteUrl); - const dataList = sp.web.getList(requestType.listId); + /** + * Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. + * + * listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из + * ManagedRequestSummary.listId, отданного searchRequests/предыдущей + * getMatrix) — не requestType.listId: та партиция может быть уже не той, где + * лежит эта заявка (см. ТЗ по архивации). + */ + public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise { + const sp = this.clientFor(siteUrl); + const dataList = sp.web.getList(listId); const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([ dataList.items .getById(requestId) - .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title') + .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode') .expand('Author')() as Promise, - this.listTitles(sp, requestType.listId) + this.listTitles(sp, listId) ]); - const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId); + const dataListUrl = listAbsUrl(siteUrl, listId); if (!approvalListTitle) { return { - request: toSummary(requestRow), - siteUrl: requestType.siteUrl, + request: toSummary(requestRow, listId), + siteUrl, dataListTitle, dataListUrl, approvalListTitle: '', @@ -241,30 +286,37 @@ export class RequestManageService { .expand(FIELDS.approver) .orderBy(FIELDS.order, true)()) as Array>; - const rows: ManagedApprovalRow[] = rawRows.map((r, i) => { - const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined; - return { - id: r.Id as number, - ticketId: requestId, - approverId: approver?.Id ?? null, - approverName: approver?.Title || '', - approverEmail: approver?.EMail || '', - position: (r.Title as string) || '', - status: (r[FIELDS.status] as string) || '', - date: formatDateIso((r[FIELDS.date] as string) || ''), - dateIso: (r[FIELDS.date] as string) || '', - comment: (r[FIELDS.comment] as string) || '', - order: (r[FIELDS.order] as number) ?? i + 1, - allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false, - phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval, - // ?? null, а не || null: ноль здесь — значимое значение («напоминать - // сразу»), и превращать его в «не задано» нельзя. - slaHours: (r[FIELDS.slaHours] as number | null) ?? null, - askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null - }; - }); + const rows: ManagedApprovalRow[] = rawRows.map((r, i) => this.toApprovalRow(r, requestId, i)); - return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows }; + return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows }; + } + + /** + * Строка списка согласования (сырые OData-поля) → ManagedApprovalRow. Общий + * маппинг для getMatrix (одна заявка, ticketId известен заранее) и + * findActiveApprovalRowsForApprover (много заявок сразу, ticketId — из самой строки). + */ + private toApprovalRow(r: Record, ticketId: number, index: number): ManagedApprovalRow { + const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined; + return { + id: r.Id as number, + ticketId, + approverId: approver?.Id ?? null, + approverName: approver?.Title || '', + approverEmail: approver?.EMail || '', + position: (r.Title as string) || '', + status: (r[FIELDS.status] as string) || '', + date: formatDateIso((r[FIELDS.date] as string) || ''), + dateIso: (r[FIELDS.date] as string) || '', + comment: (r[FIELDS.comment] as string) || '', + order: (r[FIELDS.order] as number) ?? index + 1, + allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false, + phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval, + // ?? null, а не || null: ноль здесь — значимое значение («напоминать + // сразу»), и превращать его в «не задано» нельзя. + slaHours: (r[FIELDS.slaHours] as number | null) ?? null, + askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null + }; } /** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */ @@ -274,6 +326,196 @@ export class RequestManageService { return user.Id; } + /** + * Активные строки согласования данного человека СРАЗУ по всем типам заявок и + * всем их партициям (архив + текущая) — для экрана «Заявки по согласующему» + * (ManageByUserScreen): найти всё, что нужно переназначить перед увольнением/ + * сменой должности, без похода в каждый тип заявки по отдельности. + * + * REST-only, без Search API — в отличие от request-portal/myRequests.service.ts + * (тот ищет по текущему пользователю на КАЖДОЙ загрузке страницы, и там счёт + * идёт на миллисекунды). Здесь редкий admin-вызов на одного конкретного + * сотрудника, а задержка индексации Search — это ровно тот класс багов + * (устаревшее назначение), ради исправления которого существует сам экран, так + * что жертвовать точностью ради скорости здесь не место. + * + * partitionsByType — серия партиций КАЖДОГО типа (архив+текущая), уже + * полученная вызывающей стороной через api.requestTypes.listsAll(). Partition + * даёт Title списка данных бесплатно (без похода в REST) — approvalListTitle + * выводится из него той же конвенцией имён, что и listTitles() выше. + */ + public async findActiveApprovalRowsForApprover( + requestTypes: RequestType[], + partitionsByType: Map, + approverEmail: string, + activeStatuses: string[] + ): Promise { + interface ITarget { + requestTypeId: number; + requestTypeName: string; + siteUrl: string; + dataListId: string; + approvalListTitle: string; + } + + const targets: ITarget[] = []; + requestTypes.forEach(rt => { + (partitionsByType.get(rt.id) || []).forEach(p => { + if (!p.title || !DATA_LIST_SUFFIX.test(p.title)) return; + targets.push({ + requestTypeId: rt.id, + requestTypeName: rt.name, + siteUrl: p.siteUrl, + dataListId: p.listId, + approvalListTitle: `${p.title.replace(DATA_LIST_SUFFIX, '')} — согласование` + }); + }); + }); + if (targets.length === 0) return []; + + const bySite = new Map(); + targets.forEach(t => bySite.set(t.siteUrl, [...(bySite.get(t.siteUrl) || []), t])); + + const statusFilter = activeStatuses.map(s => `${FIELDS.status} eq '${s}'`).join(' or '); + const rows: UserApprovalRow[] = []; + + await Promise.all( + Array.from(bySite.values()).map(async siteTargets => { + const siteUrl = siteTargets[0].siteUrl; + try { + const sp = this.clientFor(siteUrl); + // Один раз на сайт — тот же приём, что в fetchRecentApprovalRows + // request-portal: резолвить email в Id и фильтровать по approverId + // надёжнее, чем `TansuSiteApprover/EMail eq '...'` в $filter по + // Person-полю (последнее в SharePoint REST ненадёжно). + const approverId = await this.resolveUserId(sp, approverEmail); + + // Фаза A: строки согласования — ОДИН batch-запрос на все партиции сайта. + const approvalHitsByList = new Map>>(); + const [batchedApprovalSp, executeApproval] = sp.batched(); + const pendingApproval = siteTargets.map(t => + batchedApprovalSp.web.lists + .getByTitle(t.approvalListTitle) + .items.filter(`${FIELDS.approver}Id eq ${approverId} and (${statusFilter})`) + .select( + 'Id', + 'Title', + FIELDS.status, + FIELDS.date, + FIELDS.comment, + FIELDS.order, + FIELDS.allowReassign, + FIELDS.phase, + FIELDS.slaHours, + FIELDS.askOverdueReason, + `${FIELDS.ticket}Id`, + `${FIELDS.approver}/Id`, + `${FIELDS.approver}/Title`, + `${FIELDS.approver}/EMail` + ) + .expand(FIELDS.approver) + .top(2000)() + .then(items => { approvalHitsByList.set(t.dataListId, items as Array>); }) + .catch((e: Error) => { + // Одна сломанная/переименованная вручную партиция не должна + // лишить результата остальные партиции того же сайта. + console.error(`[Заявки по согласующему] «${t.approvalListTitle}» (${t.dataListId}): ${e.message}`); + }) + ); + await executeApproval(); + await Promise.all(pendingApproval); + + // Фаза B: Title/статус/уникальный код заявок — по найденным Id, + // сгруппированным по СПИСКУ ДАННЫХ, тоже одним batch-запросом на сайт. + const ticketIdsByList = new Map>(); + siteTargets.forEach(t => { + (approvalHitsByList.get(t.dataListId) || []).forEach(h => { + const ticketId = h[`${FIELDS.ticket}Id`] as number | undefined; + if (!ticketId) return; + const set = ticketIdsByList.get(t.dataListId) || new Set(); + set.add(ticketId); + ticketIdsByList.set(t.dataListId, set); + }); + }); + + const dataRowsByList = new Map(); + if (ticketIdsByList.size > 0) { + const [batchedDataSp, executeData] = sp.batched(); + const pendingData = Array.from(ticketIdsByList.entries()).map(([dataListId, idsSet]) => { + const ids = Array.from(idsSet); + return batchedDataSp.web + .getList(dataListId) + .items.filter(ids.map(id => `Id eq ${id}`).join(' or ')) + .select('Id', 'Title', 'RequestStatus', 'Created', 'RequestUniqueCode') + .top(ids.length)() + .then(items => { dataRowsByList.set(dataListId, items as IRawDataRow[]); }) + .catch((e: Error) => { + console.error(`[Заявки по согласующему] список данных ${dataListId}: ${e.message}`); + }); + }); + await executeData(); + await Promise.all(pendingData); + } + + // Склейка. + siteTargets.forEach(t => { + const hits = approvalHitsByList.get(t.dataListId) || []; + const dataById = new Map((dataRowsByList.get(t.dataListId) || []).map(r => [r.Id, r])); + hits.forEach((h, i) => { + const ticketId = h[`${FIELDS.ticket}Id`] as number; + if (!ticketId) return; + const dataRow = dataById.get(ticketId); + rows.push({ + ...this.toApprovalRow(h, ticketId, i), + requestTypeId: t.requestTypeId, + requestTypeName: t.requestTypeName, + siteUrl: t.siteUrl, + listId: t.dataListId, + approvalListTitle: t.approvalListTitle, + requestTitle: dataRow?.Title || `#${ticketId}`, + requestUniqueCode: dataRow?.RequestUniqueCode || null + }); + }); + }); + } catch (e) { + console.error(`[Заявки по согласующему] сайт ${siteUrl}: ${(e as Error).message}`); + } + }) + ); + + return rows; + } + + /** + * Появится ли в фазе этой заявки ВТОРОЙ согласующий с данным email, если строку + * row переназначить на него — та же проверка, что ManageRequestsScreen делает + * через excludedEmails при ручном переназначении (там она бесплатна: вся + * матрица заявки уже загружена), но здесь массовая замена бьёт по многим + * заявкам разом и своей матрицы для каждой не держит, поэтому запрашивается + * отдельно. Как и excludedEmails, смотрит на ВСЕ строки фазы независимо от + * статуса (в т.ч. уже решённые) — правило «один человек не может стоять в + * фазе дважды» не знает исключений по статусу. + */ + public async hasApproverInSamePhase( + row: Pick, + email: string + ): Promise { + const sp = this.clientFor(row.siteUrl); + const rows = (await sp.web.lists + .getByTitle(row.approvalListTitle) + .items.filter(`${FIELDS.ticket}Id eq ${row.ticketId} and ${FIELDS.phase} eq '${row.phase}'`) + .select('Id', `${FIELDS.approver}/EMail`) + .expand(FIELDS.approver) + .top(200)()) as Array>; + + const target = email.toLowerCase(); + return rows.some(r => { + if ((r.Id as number) === row.id) return false; + const approver = r[FIELDS.approver] as { EMail?: string } | undefined; + return (approver?.EMail || '').toLowerCase() === target; + }); + } + /** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана * проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */ public async reassignApprover( diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 2b76a51..669ccfb 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -77,7 +77,8 @@ export const texts = { requestTypes: 'Типы заявок', routes: 'Маршруты', resolve: 'Проверка маршрута', - manageRequests: 'Редактирование заявок' + manageRequests: 'Редактирование заявок', + manageByUser: 'Заявки по участнику' }, app: { @@ -106,6 +107,10 @@ export const texts = { manageRequestsCard: { title: 'Редактирование заявок', description: 'Правка маршрута согласования конкретной заявки' + }, + manageByUserCard: { + title: 'Заявки по участнику', + description: 'Массовая замена согласующего в заявках и шаблонах маршрутов' } }, @@ -347,7 +352,7 @@ export const texts = { projectSearchPlaceholder: 'Поиск проекта по названию...', projectClear: 'Без проекта', projectDictionaryLoading: 'Справочник проектов ещё загружается.', - departmentLabel: 'Отдел (для шага «Руководитель указанного отдела»)', + departmentLabel: 'Отдел', departmentHint: 'Заполните, если в маршруте есть шаг с типом руководителя «Руководитель указанного отдела».', departmentPlaceholder: 'Не выбран', departmentSearchPlaceholder: 'Поиск по названию отдела...', @@ -440,6 +445,65 @@ export const texts = { saveError: 'Не удалось сохранить' }, + manageByUser: { + breadcrumb: 'Заявки по участнику', + subtitle: 'Массовая замена согласующего/исполнителя', + title: 'Заявки по участнику', + employeeLabel: 'Сотрудник', + employeePlaceholder: 'Выберите сотрудника', + employeeHint: 'Покажет все его активные заявки (по всем типам и партициям) и все шаблонные шаги, где он назначен лично.', + replaceEverywhere: 'Заменить везде', + liveTitle: 'Активные заявки', + liveEmpty: 'Активных заявок нет.', + liveLoadError: 'Не удалось загрузить заявки', + columnRequestType: 'Тип заявки', + columnRequest: 'Заявка', + columnPosition: 'Должность', + columnStatus: 'Статус', + columnPhase: 'Фаза', + templatesTitle: 'Шаблоны маршрутов', + templatesEmpty: 'Шагов с этим исполнителем нет.', + templatesLoadError: 'Не удалось загрузить шаги маршрутов', + columnRoute: 'Маршрут', + columnStepStatus: 'Статус шага', + columnCondition: 'Условие', + replaceOne: 'Заменить', + selectAll: 'Выбрать все', + deselectAll: 'Снять выделение', + replaceSelected: (count: number): string => `Заменить выбранные (${count})`, + 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}`); + if (skipped > 0) parts.push(`пропущено ${skipped}`); + if (fail > 0) parts.push(`не удалось ${fail}`); + return `Заявки: ${parts.join(', ')}.`; + }, + templatesResult: (ok: number, fail: number, deleted: number, skipped: number): string => { + const parts = [`заменено ${ok}`]; + if (deleted > 0) parts.push(`удалено дублей ${deleted}`); + if (skipped > 0) parts.push(`пропущено ${skipped}`); + if (fail > 0) parts.push(`не удалось ${fail}`); + return `Шаблоны: ${parts.join(', ')}.`; + }, + duplicateModalTitle: 'Уже назначен на этом же шаге', + duplicateLiveMessage: (requestLabel: string, oldPosition: string, newEmail: string): string => + `В заявке ${requestLabel} сотрудник ${newEmail} уже согласует/исполняет этот же шаг. ` + + `Заменить строку («${oldPosition || 'без должности'}») тоже на него нельзя — появится дубль. Удалить эту строку?`, + duplicateTemplateMessage: (routeLabel: string, orderNo: number, newEmail: string): string => + `В маршруте «${routeLabel}» сотрудник ${newEmail} уже назначен на шаг с такими же параметрами. ` + + `Заменить шаг №${orderNo} на него нельзя — сервер вернул конфликт. Удалить этот шаг?`, + duplicateDelete: 'Да, удалить', + duplicateDeleteAll: 'Да для всех', + duplicateSkip: 'Нет, пропустить', + duplicateSkipAll: 'Нет для всех' + }, + // Значения по умолчанию для переиспользуемых контролов выбора. select: { placeholder: 'Выберите значение', 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; +}