Редактирование заявок у конкретного пользователя #14
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Поиск шагов шаблонов маршрутов НЕ по конкретному маршруту, а сквозной, по всем
|
||||||
|
/// маршрутам разом — отдельный top-level контроллер, а не вложенный под
|
||||||
|
/// /api/template-routes/{routeId}/steps (тот же приём, что RequestTypeListsController
|
||||||
|
/// отдельно от RequestTypesController).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/template-route-steps")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class TemplateRouteStepSearchController(TemplateRouteStepService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Шаги ВСЕХ маршрутов с данным ExecutorEmail — для экрана «заменить согласующего everywhere».</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string executorEmail, CancellationToken ct) =>
|
||||||
|
(await service.ListByExecutorEmailAsync(executorEmail, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -40,20 +40,34 @@ public sealed class ReminderService(
|
|||||||
.Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null)
|
.Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
// Типы независимы (разные сайты/списки в SharePoint) — их обход
|
// Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним DbContext — он не
|
||||||
// распараллелен, иначе время растёт линейно с числом типов: каждый тип
|
// потокобезопасен, и параллельный вызов GetArchivedAsync из нескольких
|
||||||
// сам по себе делает несколько последовательных HTTP-запросов.
|
// веток Task.WhenAll ниже валит его исключением ("A second operation
|
||||||
var results = await Task.WhenAll(types.Select(type => GetOverdueForTypeSafeAsync(type, now, cancellationToken)));
|
// was started on this context instance before a previous operation
|
||||||
|
// completed"), которое try/catch в GetOverdueForTypeSafeAsync тихо
|
||||||
|
// проглатывает — часть напоминаний молча пропадала бы без всякой
|
||||||
|
// видимой ошибки. Сама выборка — без HTTP, быстрая, последовательность
|
||||||
|
// здесь не стоит ощутимого времени.
|
||||||
|
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||||||
|
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<IReadOnlyList<ReminderDto>>(results.SelectMany(r => r).ToList());
|
return Result.Ok<IReadOnlyList<ReminderDto>>(results.SelectMany(r => r).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeSafeAsync(
|
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeSafeAsync(
|
||||||
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await GetOverdueForTypeAsync(type, now, cancellationToken);
|
return await GetOverdueForTypeAsync(type, archived, now, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@@ -70,14 +84,13 @@ public sealed class ReminderService(
|
|||||||
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
||||||
/// новых заявок завели следующий список.
|
/// новых заявок завели следующий список.
|
||||||
///
|
///
|
||||||
/// request_list_partitions хранит только архив (см. ListPartitionRegistry) —
|
/// archived — уже прочитан вызывающей стороной (GetOverdueAsync,
|
||||||
/// текущая партиция синтезируется отдельно и добавляется к архиву здесь же,
|
/// последовательно для всех типов разом) — здесь БД больше не трогаем,
|
||||||
/// а не читается из БД.
|
/// только HTTP к SharePoint, поэтому вызов можно параллелить по типам.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
||||||
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
|
||||||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
|
|
||||||
var partitions = archived
|
var partitions = archived
|
||||||
|
|||||||
@@ -25,6 +25,29 @@ public sealed class TemplateRouteStepService(
|
|||||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для
|
||||||
|
/// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen):
|
||||||
|
/// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не
|
||||||
|
/// попадают — это не привязка к конкретному человеку.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListByExecutorEmailAsync(
|
||||||
|
string executorEmail, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(executorEmail))
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
|
||||||
|
|
||||||
|
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<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
|
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
||||||
|
|||||||
+40
@@ -511,4 +511,44 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
fetched.IsFailed.ShouldBeTrue();
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "ListByExecutorEmailAsync находит шаги с данным исполнителем сразу по разным маршрутам, регистронезависимо")]
|
||||||
|
public async Task ListByExecutorEmailAsync_FindsStepsAcrossRoutes_CaseInsensitive()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeAId = await CreateRouteAsync("Route A for executor search", byProject: false);
|
||||||
|
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
|
||||||
|
await _service.CreateAsync(
|
||||||
|
routeAId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
await _service.CreateAsync(
|
||||||
|
routeBId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
// Другой исполнитель — не должен попасть в выдачу.
|
||||||
|
await _service.CreateAsync(
|
||||||
|
routeAId,
|
||||||
|
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var result = await _service.ListByExecutorEmailAsync("TARGET@EXAMPLE.KZ", CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsSuccess.ShouldBeTrue();
|
||||||
|
result.Value.Count.ShouldBe(2);
|
||||||
|
result.Value.ShouldAllBe(x => 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
||||||
Generated
+13
@@ -17,6 +17,7 @@
|
|||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
|
"@tnsu/ui-kit": "^0.4.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
@@ -6281,6 +6282,18 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
"@pnp/sp": "^4.20.0",
|
|
||||||
"@microsoft/sp-component-base": "1.22.1",
|
"@microsoft/sp-component-base": "1.22.1",
|
||||||
"@microsoft/sp-core-library": "1.22.1",
|
"@microsoft/sp-core-library": "1.22.1",
|
||||||
"@microsoft/sp-http": "1.22.1",
|
"@microsoft/sp-http": "1.22.1",
|
||||||
@@ -21,6 +20,8 @@
|
|||||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "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": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"requiresCustomScript": false,
|
"requiresCustomScript": false,
|
||||||
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
|
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
|
||||||
"supportsThemeVariants": true,
|
"supportsThemeVariants": true,
|
||||||
|
"supportsFullBleed": true,
|
||||||
"preconfiguredEntries": [{
|
"preconfiguredEntries": [{
|
||||||
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
||||||
"group": { "default": "Advanced" },
|
"group": { "default": "Advanced" },
|
||||||
|
|||||||
@@ -69,10 +69,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
|||||||
{ key: API_BASE_URL_LOCAL, text: `Локально (${API_BASE_URL_LOCAL})` }
|
{ key: API_BASE_URL_LOCAL, text: `Локально (${API_BASE_URL_LOCAL})` }
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
PropertyPaneTextField('aadResourceId', {
|
|
||||||
label: 'AAD Resource Id (App ID URI)',
|
|
||||||
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
|
|
||||||
}),
|
|
||||||
PropertyPaneTextField('portalBaseUrl', {
|
PropertyPaneTextField('portalBaseUrl', {
|
||||||
label: 'Портал справочников (api.tnsu.kz)',
|
label: 'Портал справочников (api.tnsu.kz)',
|
||||||
description: 'Откуда берутся списки компаний и отделов. Ходим напрямую, тем же AAD-токеном.'
|
description: 'Откуда берутся списки компаний и отделов. Ходим напрямую, тем же AAD-токеном.'
|
||||||
|
|||||||
@@ -1,23 +1,63 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Раскладка — тот же боковой рейл + основная область, что и в request-portal
|
||||||
|
// (@tnsu/ui-kit Sidebar, см. RequestPortal.module.scss): единый стиль
|
||||||
|
// навигации во всех веб-частях Tnsu. В отличие от request-portal (рейл во всю
|
||||||
|
// высоту окна, edge-to-edge), здесь виджет — ограниченная карточка на странице
|
||||||
|
// SharePoint, поэтому без sticky/100vh-трюков: рейл просто растягивается по
|
||||||
|
// высоте своего соседа (.main) через align-items: stretch.
|
||||||
.shell {
|
.shell {
|
||||||
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
border-radius: $r-lg;
|
border-radius: $r-lg;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: $sh-1;
|
box-shadow: $sh-1;
|
||||||
color: $gray-900;
|
|
||||||
font-family: $font-sans;
|
font-family: $font-sans;
|
||||||
font-size: $fs-body;
|
font-size: $fs-body;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
background: $navy-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0; // чтобы контент не распирал колонку
|
||||||
|
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 28px 30px 36px;
|
padding: 28px 30px 36px;
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Мобильная кнопка-бургер (открывает выезжающий Sidebar). На десктопе скрыта.
|
||||||
|
.mobileNav {
|
||||||
|
display: none;
|
||||||
|
padding: $sp-4 $sp-6 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// < 768px: рейл сам уходит в position:fixed и уезжает за левый край экрана,
|
||||||
|
// пока не открыт (см. @tnsu/ui-kit Sidebar — это его собственный CSS, чисто
|
||||||
|
// media-query, без участия JS) — НЕ display:none на .side: рейл вёрстке всё
|
||||||
|
// равно нужен в потоке, чтобы при mobileOpen выехать поверх контента.
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobileNav {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.centerState {
|
.centerState {
|
||||||
padding: 60px 30px;
|
padding: 60px 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,19 +1,47 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './AdminApp.module.scss';
|
import styles from './AdminApp.module.scss';
|
||||||
import { TopNav } from './TopNav';
|
import '@tnsu/ui-kit/styles.css';
|
||||||
|
import { IconSprite, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
|
||||||
|
import type { ISidebarItem } from '@tnsu/ui-kit';
|
||||||
import { ScreenKey } from './types';
|
import { ScreenKey } from './types';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { SharePointListsClient } from '../../services/sharePointLists';
|
import { SharePointListsClient } from '../../services/sharePointLists';
|
||||||
import { RequestManageService } from '../../services/requestManageService';
|
import { RequestManageService } from '../../services/requestManageService';
|
||||||
import { RequestType, TemplateRoute } from '../../models';
|
import { RequestType, TemplateRoute } from '../../models';
|
||||||
import { navigate, useRoute } from '../../routing';
|
import { navigate, replaceRoute, useRoute } from '../../routing';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||||
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||||
|
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
||||||
|
|
||||||
|
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
||||||
|
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
||||||
|
// отдельная вкладка URL, а состояние вкладки 'routes' при открытых шагах
|
||||||
|
// конкретного маршрута (см. routing.ts) — подсвечиваем в этом случае 'routes'.
|
||||||
|
const NAV_ITEMS: ISidebarItem[] = [
|
||||||
|
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
||||||
|
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||||
|
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||||
|
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||||
|
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||||
|
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function sidebarActiveId(tab: ScreenKey): string {
|
||||||
|
return tab === 'routeSteps' ? 'routes' : tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Последняя открытая вкладка. В адресе она и так живёт (aatab, см. routing.ts),
|
||||||
|
// но веб-часть часто открывают по «чистой» ссылке на страницу SharePoint, без
|
||||||
|
// query — и тогда пользователь каждый раз приземлялся на дашборд, даже если
|
||||||
|
// работает всегда в одном разделе.
|
||||||
|
const TAB_STORAGE_KEY = 'approvalAdmin.activeTab';
|
||||||
|
|
||||||
export interface IAdminAppProps {
|
export interface IAdminAppProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
@@ -28,8 +56,31 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||||
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
||||||
|
const [navCollapsed, setNavCollapsed] = React.useState(false);
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
|
||||||
|
|
||||||
const navigateTab = (key: ScreenKey): void => navigate({ tab: key });
|
const [storedTab, setStoredTab] = useLocalStorageState<ScreenKey>(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 => {
|
const openSteps = (r: TemplateRoute): void => {
|
||||||
setSelectedRoute(r);
|
setSelectedRoute(r);
|
||||||
@@ -76,41 +127,61 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
<TopNav active={route.tab} onSelect={navigateTab} />
|
<IconSprite />
|
||||||
<div className={styles.content}>
|
<div className={styles.side}>
|
||||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
<Sidebar
|
||||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
items={NAV_ITEMS}
|
||||||
{route.tab === 'routes' && !route.routeId && (
|
activeId={sidebarActiveId(route.tab)}
|
||||||
<RoutesScreen
|
onChange={id => navigateTab(id as ScreenKey)}
|
||||||
api={api}
|
brand={texts.nav.title}
|
||||||
dictionary={dictionary}
|
collapsed={navCollapsed}
|
||||||
initialFilterRequestTypeId={route.routesFilter}
|
onToggleCollapsed={() => setNavCollapsed(v => !v)}
|
||||||
onOpenSteps={openSteps}
|
mobileOpen={mobileNavOpen}
|
||||||
/>
|
onMobileClose={() => setMobileNavOpen(false)}
|
||||||
)}
|
/>
|
||||||
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
|
||||||
<RouteStepsScreen
|
|
||||||
api={api}
|
|
||||||
route={selectedRoute}
|
|
||||||
requestType={selectedRequestType}
|
|
||||||
dictionary={dictionary}
|
|
||||||
sharePoint={sharePoint}
|
|
||||||
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{route.tab === 'resolve' && (
|
|
||||||
<ResolveScreen
|
|
||||||
api={api}
|
|
||||||
dictionary={dictionary}
|
|
||||||
sharePoint={sharePoint}
|
|
||||||
currentUserEmail={currentUserEmail}
|
|
||||||
route={route.resolve}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{route.tab === 'manageRequests' && (
|
|
||||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<main className={styles.main}>
|
||||||
|
<div className={styles.mobileNav}>
|
||||||
|
<SidebarToggle onClick={() => setMobileNavOpen(true)} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||||
|
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||||
|
{route.tab === 'routes' && !route.routeId && (
|
||||||
|
<RoutesScreen
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
initialFilterRequestTypeId={route.routesFilter}
|
||||||
|
onOpenSteps={openSteps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
||||||
|
<RouteStepsScreen
|
||||||
|
api={api}
|
||||||
|
route={selectedRoute}
|
||||||
|
requestType={selectedRequestType}
|
||||||
|
dictionary={dictionary}
|
||||||
|
sharePoint={sharePoint}
|
||||||
|
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{route.tab === 'resolve' && (
|
||||||
|
<ResolveScreen
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
sharePoint={sharePoint}
|
||||||
|
currentUserEmail={currentUserEmail}
|
||||||
|
route={route.resolve}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{route.tab === 'manageRequests' && (
|
||||||
|
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||||
|
)}
|
||||||
|
{route.tab === 'manageByUser' && (
|
||||||
|
<ManageByUserScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manageByUser} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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<ITopNavProps> = ({ active, onSelect }) => {
|
|
||||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const activeTabRef = React.useRef<HTMLButtonElement>(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 (
|
|
||||||
<nav className={styles.topnav}>
|
|
||||||
<div className={styles.brand}>
|
|
||||||
<div className={styles.brandText}>{texts.nav.title}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.tabs} ref={tabsRef}>
|
|
||||||
{TABS.map(tab => {
|
|
||||||
const on = isTabActive(active, tab.key);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
ref={on ? activeTabRef : undefined}
|
|
||||||
className={`${styles.tab} ${on ? styles.on : ''}`}
|
|
||||||
onClick={() => onSelect(tab.key)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +1 @@
|
|||||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests';
|
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser';
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNaviga
|
|||||||
description={texts.dashboard.manageRequestsCard.description}
|
description={texts.dashboard.manageRequestsCard.description}
|
||||||
onClick={() => onNavigate('manageRequests')}
|
onClick={() => onNavigate('manageRequests')}
|
||||||
/>
|
/>
|
||||||
|
<NavCard
|
||||||
|
icon="people"
|
||||||
|
title={texts.dashboard.manageByUserCard.title}
|
||||||
|
description={texts.dashboard.manageByUserCard.description}
|
||||||
|
onClick={() => onNavigate('manageByUser')}
|
||||||
|
/>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<IManageByUserDuplicateModalProps> = ({ title, message, onChoice }) => (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
onClose={() => onChoice('skip', false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => onChoice('skip', false)}>{texts.manageByUser.duplicateSkip}</Button>
|
||||||
|
<Button variant="secondary" onClick={() => onChoice('skip', true)}>{texts.manageByUser.duplicateSkipAll}</Button>
|
||||||
|
<Button variant="danger" icon="trash" onClick={() => onChoice('delete', false)}>{texts.manageByUser.duplicateDelete}</Button>
|
||||||
|
<Button variant="danger" icon="trash" onClick={() => onChoice('delete', true)}>{texts.manageByUser.duplicateDeleteAll}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>{message}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
|
||||||
|
const requestTypes = useRequestTypes(api);
|
||||||
|
const listSeries = useRequestTypeListsAll(api);
|
||||||
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
const dictionaries = usePortalDictionaries(dictionary);
|
||||||
|
|
||||||
|
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(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<number, RequestListPartition[]>();
|
||||||
|
listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
|
||||||
|
return map;
|
||||||
|
}, [listSeries.items]);
|
||||||
|
|
||||||
|
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
|
||||||
|
const [liveLoading, setLiveLoading] = React.useState(false);
|
||||||
|
const [liveError, setLiveError] = React.useState<string | undefined>();
|
||||||
|
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(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<Set<number>>(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<DuplicateChoice | undefined>(undefined);
|
||||||
|
|
||||||
|
// Шаг вперёд по полосе прогресса. Вызывается ПЕРЕД обработкой очередной
|
||||||
|
// строки: показывает «делаю N-ю из M», а не «N уже готово».
|
||||||
|
const advanceProgress = (): void =>
|
||||||
|
setProgress(prev => (prev ? { done: Math.min(prev.done + 1, prev.total), total: prev.total } : prev));
|
||||||
|
|
||||||
|
const askDuplicate = (message: string): Promise<DuplicateChoice> => {
|
||||||
|
if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current);
|
||||||
|
return new Promise<DuplicateChoice>(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<IBulkOutcome> {
|
||||||
|
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<IBulkOutcome> {
|
||||||
|
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<void> => {
|
||||||
|
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<UserApprovalRow>[] = [
|
||||||
|
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedLiveKeys.has(liveRowKey(r))} onChange={() => toggleLive(r)} /> },
|
||||||
|
{ key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName },
|
||||||
|
{
|
||||||
|
key: 'request',
|
||||||
|
header: texts.manageByUser.columnRequest,
|
||||||
|
render: r => <PrimaryCell>{`${r.requestUniqueCode || `#${r.ticketId}`} ${r.requestTitle}`}</PrimaryCell>
|
||||||
|
},
|
||||||
|
{ key: 'position', header: texts.manageByUser.columnPosition, render: r => r.position || texts.common.empty },
|
||||||
|
{ key: 'status', header: texts.manageByUser.columnStatus, render: r => <Badge variant={statusVariant(r.status)}>{r.status}</Badge> },
|
||||||
|
{ key: 'phase', header: texts.manageByUser.columnPhase, render: r => r.phase },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
sticky: true,
|
||||||
|
render: r => (
|
||||||
|
<ActionsCell>
|
||||||
|
<IconButton icon="edit" label={texts.manageByUser.replaceOne} disabled={!canReplace} onClick={() => runReplace({ live: [r], templates: [] })} />
|
||||||
|
</ActionsCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const templateColumns: IColumn<UserTemplateStepRow>[] = [
|
||||||
|
{ key: 'check', header: '', width: '32px', render: r => <Checkbox label="" checked={checkedTemplateIds.has(r.id)} onChange={() => toggleTemplate(r)} /> },
|
||||||
|
{
|
||||||
|
key: 'route',
|
||||||
|
header: texts.manageByUser.columnRoute,
|
||||||
|
render: r => <PrimaryCell>{`${r.requestTypeName} — ${r.companyName} / ${r.departmentName}`}</PrimaryCell>
|
||||||
|
},
|
||||||
|
{ 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 => (
|
||||||
|
<ActionsCell>
|
||||||
|
<IconButton icon="edit" label={texts.manageByUser.replaceOne} disabled={!canReplace} onClick={() => runReplace({ live: [], templates: [r] })} />
|
||||||
|
</ActionsCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasAnyRows = liveRows.length > 0 || templateRows.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageByUser.breadcrumb }]} />
|
||||||
|
<PageHeader
|
||||||
|
icon="people"
|
||||||
|
subtitle={texts.manageByUser.subtitle}
|
||||||
|
title={texts.manageByUser.title}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
disabled={!canReplace || !hasAnyRows}
|
||||||
|
onClick={() => runReplace({ live: liveRows, templates: templateRows })}
|
||||||
|
>
|
||||||
|
{texts.manageByUser.replaceEverywhere}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<FormGrid>
|
||||||
|
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint}>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employees.items.map(toEmployeeOption)}
|
||||||
|
value={employeeEmail}
|
||||||
|
loading={employees.isLoading}
|
||||||
|
placeholder={texts.manageByUser.employeePlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={selectEmployee}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{/* Кого ставим вместо — рядом, а не в модалке по кнопке: замена обычно
|
||||||
|
идёт в несколько приёмов (сначала заявки, потом маршруты, потом
|
||||||
|
отдельные строки), и выбирать одного и того же человека заново
|
||||||
|
на каждый приём незачем. */}
|
||||||
|
<Field label={texts.manageByUser.targetLabel} required hint={texts.manageByUser.targetHint}>
|
||||||
|
<div className={styles.targetRow}>
|
||||||
|
<div className={styles.targetSelect}>
|
||||||
|
<SearchableSelect
|
||||||
|
options={targetOptions}
|
||||||
|
value={targetEmail}
|
||||||
|
loading={employees.isLoading}
|
||||||
|
placeholder={texts.manageByUser.targetPlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
allowClear
|
||||||
|
clearLabel={texts.manageByUser.targetClear}
|
||||||
|
onChange={selectTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Обратная замена — обычное продолжение работы («вернуть всё
|
||||||
|
как было», «а теперь наоборот»), и переставлять двух человек
|
||||||
|
руками через два поиска по справочнику ради этого не нужно. */}
|
||||||
|
<IconButton
|
||||||
|
icon="sort"
|
||||||
|
label={texts.manageByUser.swap}
|
||||||
|
disabled={!employeeEmail && !targetEmail}
|
||||||
|
onClick={swapEmployees}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{employeeEmail && (
|
||||||
|
<Panel>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div className={styles.panelTitle}>{`${texts.manageByUser.liveTitle} (${liveRows.length})`}</div>
|
||||||
|
<div className={styles.panelActions}>
|
||||||
|
<Button variant="ghost" size="sm" disabled={liveRows.length === 0} onClick={toggleAllLive}>
|
||||||
|
{allLiveChecked ? texts.manageByUser.deselectAll : texts.manageByUser.selectAll}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canReplace || checkedLiveRows.length === 0}
|
||||||
|
onClick={() => runReplace({ live: checkedLiveRows, templates: [] })}
|
||||||
|
>
|
||||||
|
{texts.manageByUser.replaceSelected(checkedLiveRows.length)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{liveError && <Alert kind="err">{`${texts.manageByUser.liveLoadError}: ${liveError}`}</Alert>}
|
||||||
|
{liveLoading && <div>{texts.common.loading}</div>}
|
||||||
|
{!liveLoading && !liveError && (
|
||||||
|
<DataTable columns={liveColumns} rows={liveRows} keyOf={liveRowKey} emptyMessage={texts.manageByUser.liveEmpty} />
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{employeeEmail && (
|
||||||
|
<Panel>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div className={styles.panelTitle}>{`${texts.manageByUser.templatesTitle} (${templateRows.length})`}</div>
|
||||||
|
<div className={styles.panelActions}>
|
||||||
|
<Button variant="ghost" size="sm" disabled={templateRows.length === 0} onClick={toggleAllTemplates}>
|
||||||
|
{allTemplatesChecked ? texts.manageByUser.deselectAll : texts.manageByUser.selectAll}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canReplace || checkedTemplateRows.length === 0}
|
||||||
|
onClick={() => runReplace({ live: [], templates: checkedTemplateRows })}
|
||||||
|
>
|
||||||
|
{texts.manageByUser.replaceSelected(checkedTemplateRows.length)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{templateSteps.error && <Alert kind="err">{`${texts.manageByUser.templatesLoadError}: ${templateSteps.error}`}</Alert>}
|
||||||
|
{templateSteps.isLoading && <div>{texts.common.loading}</div>}
|
||||||
|
{!templateSteps.isLoading && !templateSteps.error && (
|
||||||
|
<DataTable columns={templateColumns} rows={templateRows} keyOf={r => r.id} emptyMessage={texts.manageByUser.templatesEmpty} />
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{progress && (
|
||||||
|
<ProgressOverlay label={texts.manageByUser.progressLabel} done={progress.done} total={progress.total} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{duplicatePrompt && (
|
||||||
|
<ManageByUserDuplicateModal
|
||||||
|
title={texts.manageByUser.duplicateModalTitle}
|
||||||
|
message={duplicatePrompt.message}
|
||||||
|
onChoice={answerDuplicate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resultToast && (
|
||||||
|
<Toast message={resultToast.message} kind={resultToast.kind} onClose={() => setResultToast(undefined)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../../models';
|
} from '../../models';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
|
import { useRequestTypeListPartitions } from '../../hooks/useRequestTypeListPartitions';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { findEmployeeByEmail } from '../../utils/employee';
|
import { findEmployeeByEmail } from '../../utils/employee';
|
||||||
@@ -47,6 +48,14 @@ export interface IManageRequestsScreenProps {
|
|||||||
route?: IManageRequestRouteParams;
|
route?: IManageRequestRouteParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подпись заявки в комбобоксе — уникальный код (RequestUniqueCode, тот же, что
|
||||||
|
// видит заявитель на портале), а не служебный числовой Id списка SharePoint.
|
||||||
|
// Запасной вариант на #id — для заявок без заполненного кода (старые заявки,
|
||||||
|
// либо список данных этой колонки не заводит).
|
||||||
|
function requestLabel(r: Pick<ManagedRequestSummary, 'id' | 'title' | 'uniqueCode'>): string {
|
||||||
|
return `${r.uniqueCode || `#${r.id}`} ${r.title}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Цвет плашки статуса — те же группы смысла, что и в request-portal
|
// Цвет плашки статуса — те же группы смысла, что и в request-portal
|
||||||
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
|
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
|
||||||
function statusVariant(status: string): BadgeVariant {
|
function statusVariant(status: string): BadgeVariant {
|
||||||
@@ -158,7 +167,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
if (!route) return;
|
if (!route) return;
|
||||||
setSelection({
|
setSelection({
|
||||||
requestTypeId: route.requestTypeId,
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -167,11 +188,15 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
|
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
|
||||||
// состояния текущего экрана, а не переход на новую страницу с отдельным
|
// состояния текущего экрана, а не переход на новую страницу с отдельным
|
||||||
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
|
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
|
||||||
// сам requestId, хранить производные данные в ссылке незачем.
|
// сам requestId, хранить производные данные в ссылке незачем. listId —
|
||||||
|
// пишем: без него по ссылке нельзя однозначно понять, в какой партиции
|
||||||
|
// искать requestId (см. IManageRequestRouteParams).
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
replaceRoute({
|
replaceRoute({
|
||||||
tab: 'manageRequests',
|
tab: 'manageRequests',
|
||||||
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined
|
manage: selectedRequest
|
||||||
|
? { requestTypeId, requestId: String(selectedRequest.id), listId: selectedRequest.listId }
|
||||||
|
: undefined
|
||||||
});
|
});
|
||||||
}, [requestTypeId, selectedRequest]);
|
}, [requestTypeId, selectedRequest]);
|
||||||
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
|
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
|
||||||
@@ -192,6 +217,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
// и читать заявки этого типа отсюда просто негде.
|
// и читать заявки этого типа отсюда просто негде.
|
||||||
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
|
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
|
||||||
|
|
||||||
|
// Вся серия партиций выбранного типа (архив + текущая) — заявки этого типа
|
||||||
|
// физически лежат в любой из них (см. ТЗ по архивации), искать и открывать
|
||||||
|
// нужно по всем сразу, не только по текущей.
|
||||||
|
const partitions = useRequestTypeListPartitions(api, requestType?.id);
|
||||||
|
|
||||||
const selectRequestType = (id: string): void => {
|
const selectRequestType = (id: string): void => {
|
||||||
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
|
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
|
||||||
// показывать заявку не того типа, что выбран сейчас.
|
// показывать заявку не того типа, что выбран сейчас.
|
||||||
@@ -205,10 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
|
|
||||||
const searchRequests = React.useCallback(
|
const searchRequests = React.useCallback(
|
||||||
(query: string, page: number) => {
|
(query: string, page: number) => {
|
||||||
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
|
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
|
||||||
return requestManage.searchRequests(requestType, query, page);
|
return Promise.resolve({ items: [], hasMore: false });
|
||||||
|
}
|
||||||
|
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
|
||||||
},
|
},
|
||||||
[requestManage, requestType]
|
[requestManage, requestType, partitions.items]
|
||||||
);
|
);
|
||||||
|
|
||||||
// silent=true — фоновое обновление (после своего же действия, автообновление
|
// silent=true — фоновое обновление (после своего же действия, автообновление
|
||||||
@@ -219,18 +251,38 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
const reloadMatrix = React.useCallback(
|
const reloadMatrix = React.useCallback(
|
||||||
(silent = false) => {
|
(silent = false) => {
|
||||||
if (!requestType || !selectedRequest) return;
|
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);
|
if (!silent) setMatrixLoading(true);
|
||||||
setMatrixError(undefined);
|
setMatrixError(undefined);
|
||||||
requestManage
|
requestManage
|
||||||
.getMatrix(requestType, selectedRequest.id)
|
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
|
||||||
.then(m => {
|
.then(m => {
|
||||||
setMatrix(m);
|
setMatrix(m);
|
||||||
// title/status в комбобоксе (а значит, и в localStorage) держим
|
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
|
||||||
// синхронными с сервером — особенно важно после захода по глубокой
|
// синхронными с сервером — особенно важно после захода по глубокой
|
||||||
// ссылке, где изначально они пустые (см. эффект-затравку выше).
|
// ссылке, где изначально они пустые (см. эффект-затравку выше), и
|
||||||
|
// после подстановки запасной (текущей) партиции выше: как только
|
||||||
|
// реальная партиция заявки известна, оседает в выборе — следующий
|
||||||
|
// reloadMatrix пойдёт уже по ней напрямую.
|
||||||
//
|
//
|
||||||
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не
|
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если ничего не
|
||||||
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг
|
// отличается — m.request это каждый раз НОВЫЙ объект (свежий парсинг
|
||||||
// ответа), и если писать его в состояние безусловно, selectedRequest
|
// ответа), и если писать его в состояние безусловно, selectedRequest
|
||||||
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
|
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
|
||||||
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
|
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
|
||||||
@@ -240,7 +292,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
setSelection(prev => {
|
setSelection(prev => {
|
||||||
const sel = prev.selectedRequest;
|
const sel = prev.selectedRequest;
|
||||||
if (!sel || sel.id !== m.request.id) return prev;
|
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 };
|
return same ? prev : { ...prev, selectedRequest: m.request };
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -249,7 +301,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
if (!silent) setMatrixLoading(false);
|
if (!silent) setMatrixLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[requestManage, requestType, selectedRequest]
|
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -533,14 +585,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
<AsyncSearchableSelect<ManagedRequestSummary>
|
<AsyncSearchableSelect<ManagedRequestSummary>
|
||||||
key={requestTypeId || 'none'}
|
key={requestTypeId || 'none'}
|
||||||
value={selectedRequest ? String(selectedRequest.id) : ''}
|
value={selectedRequest ? String(selectedRequest.id) : ''}
|
||||||
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined}
|
selectedLabel={selectedRequest ? requestLabel(selectedRequest) : undefined}
|
||||||
selectedSublabel={selectedRequest?.status}
|
selectedSublabel={selectedRequest?.status}
|
||||||
disabled={!requestType}
|
disabled={!requestType}
|
||||||
placeholder={texts.manageRequests.requestPlaceholder}
|
placeholder={texts.manageRequests.requestPlaceholder}
|
||||||
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
|
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
|
||||||
search={searchRequests}
|
search={searchRequests}
|
||||||
getValue={r => String(r.id)}
|
getValue={r => String(r.id)}
|
||||||
getLabel={r => `#${r.id} ${r.title}`}
|
getLabel={requestLabel}
|
||||||
getSublabel={r => r.status}
|
getSublabel={r => r.status}
|
||||||
onChange={(_, item) => selectRequest(item)}
|
onChange={(_, item) => selectRequest(item)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -50,8 +50,14 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wrap обязателен, а не косметика: кнопки .btn — white-space: nowrap, сжаться по
|
||||||
|
// тексту не могут, а justify-content: flex-end выталкивает не влезающие ВЛЕВО,
|
||||||
|
// за границу .box — и они просто исчезают из виду (так пропадала кнопка «Нет,
|
||||||
|
// пропустить» в диалоге на четыре варианта, см. ManageByUserDuplicateModal).
|
||||||
|
// С переносом лишние кнопки уезжают на вторую строку и остаются доступны.
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 16px 26px 22px;
|
padding: 16px 26px 22px;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может
|
||||||
|
// открыть сама длительная операция (например, разрешение конфликта), должны
|
||||||
|
// быть ПОВЕРХ этой подложки, а не под ней.
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 250;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(10, 27, 48, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 24px 26px;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: $sh-modal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
margin-top: 14px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: $gray-200;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: $grad-gold;
|
||||||
|
transition: width .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общее число шагов заранее неизвестно — бегущая полоса вместо доли.
|
||||||
|
.indeterminate {
|
||||||
|
width: 40%;
|
||||||
|
animation: slide 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(250%); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ProgressOverlay.module.scss';
|
||||||
|
|
||||||
|
export interface IProgressOverlayProps {
|
||||||
|
label: string;
|
||||||
|
/** Сколько шагов уже сделано. */
|
||||||
|
done: number;
|
||||||
|
/** Всего шагов. 0/undefined — полоса «бежит», доля не показывается. */
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальная подложка с полосой прогресса на время длительной пакетной операции:
|
||||||
|
* показывает, что работа идёт и сколько осталось, и заодно физически не даёт
|
||||||
|
* кликать по интерфейсу под ней (иначе можно запустить вторую замену поверх
|
||||||
|
* первой или уйти на другую вкладку посреди записи).
|
||||||
|
*
|
||||||
|
* Закрыть её нельзя намеренно — операция уже пишет в SharePoint/БД, и «отмена»
|
||||||
|
* оставила бы часть строк заменённой, а часть нет, без всякого следа об этом.
|
||||||
|
*/
|
||||||
|
export const ProgressOverlay: React.FC<IProgressOverlayProps> = ({ label, done, total }) => {
|
||||||
|
const percent = total && total > 0 ? Math.min(100, Math.round((done / total) * 100)) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} role="alert" aria-busy="true">
|
||||||
|
<div className={styles.box}>
|
||||||
|
<div className={styles.label}>{label}</div>
|
||||||
|
{total ? <div className={styles.counter}>{`${done} / ${total}`}</div> : undefined}
|
||||||
|
<div className={styles.track}>
|
||||||
|
<div
|
||||||
|
className={`${styles.fill} ${percent === undefined ? styles.indeterminate : ''}`}
|
||||||
|
style={percent === undefined ? undefined : { width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ export * from './IconButton';
|
|||||||
export * from './Badge';
|
export * from './Badge';
|
||||||
export * from './Alert';
|
export * from './Alert';
|
||||||
export * from './Toast';
|
export * from './Toast';
|
||||||
|
export * from './ProgressOverlay';
|
||||||
export * from './Panel';
|
export * from './Panel';
|
||||||
export * from './Breadcrumbs';
|
export * from './Breadcrumbs';
|
||||||
export * from './PageHeader';
|
export * from './PageHeader';
|
||||||
|
|||||||
@@ -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<RequestListPartition> {
|
||||||
|
const load = React.useCallback(
|
||||||
|
() => (requestTypeId ? api.requestTypes.lists(requestTypeId) : Promise.resolve([])),
|
||||||
|
[api, requestTypeId]
|
||||||
|
);
|
||||||
|
return useAsyncList(load);
|
||||||
|
}
|
||||||
@@ -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<TemplateRouteStep> {
|
||||||
|
const load = React.useCallback(
|
||||||
|
() => (executorEmail ? api.templateRouteSteps.listByExecutorEmail(executorEmail) : Promise.resolve([])),
|
||||||
|
[api, executorEmail]
|
||||||
|
);
|
||||||
|
return useAsyncList(load);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TemplateRoute } from '../models';
|
||||||
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
|
|
||||||
|
export interface TemplateRoutesLookup {
|
||||||
|
byId: Map<number, TemplateRoute>;
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дочитывает TemplateRoute по списку id (например, найденных через
|
||||||
|
* useTemplateRouteStepsByExecutor) — bulk-эндпоинта нет, поэтому по одному
|
||||||
|
* запросу на маршрут параллельно. Нужен для отображения (тип заявки/компания/
|
||||||
|
* отдел маршрута), сам TemplateRouteStepDto хранит только routeId.
|
||||||
|
*/
|
||||||
|
export function useTemplateRoutesLookup(api: ApprovalRouteApi, routeIds: number[]): TemplateRoutesLookup {
|
||||||
|
const [byId, setById] = React.useState<Map<number, TemplateRoute>>(new Map());
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
// Стабильный ключ вместо самого массива 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 };
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ export * from './templateRoutes';
|
|||||||
export * from './templateRouteSteps';
|
export * from './templateRouteSteps';
|
||||||
export * from './resolve';
|
export * from './resolve';
|
||||||
export * from './requestManage';
|
export * from './requestManage';
|
||||||
|
export * from './manageByUser';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -78,6 +78,21 @@ export interface ManagedRequestSummary {
|
|||||||
status: string;
|
status: string;
|
||||||
created: string; // ISO
|
created: string; // ISO
|
||||||
authorName: string;
|
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 {
|
export interface ManagedRequestPage {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('parseRoute', () => {
|
describe('parseRoute', () => {
|
||||||
it('без aatab — вкладка по умолчанию dashboard, всё остальное пусто', () => {
|
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', () => {
|
it('незнакомое значение aatab тоже откатывается на dashboard', () => {
|
||||||
@@ -19,7 +25,12 @@ describe('parseRoute', () => {
|
|||||||
it('manageRequests с aamtype+aamreq — даёт заполненный manage', () => {
|
it('manageRequests с aamtype+aamreq — даёт заполненный manage', () => {
|
||||||
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42');
|
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42');
|
||||||
expect(route.tab).toBe('manageRequests');
|
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 заявки обязателен)', () => {
|
it('manageRequests без aamreq — manage не собирается (id заявки обязателен)', () => {
|
||||||
@@ -27,6 +38,25 @@ describe('parseRoute', () => {
|
|||||||
expect(route.manage).toBeUndefined();
|
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', () => {
|
it('aaroute учитывается только при tab=routes', () => {
|
||||||
expect(parseRoute('?aatab=routes&aaroute=7').routeId).toBe(7);
|
expect(parseRoute('?aatab=routes&aaroute=7').routeId).toBe(7);
|
||||||
expect(parseRoute('?aatab=manageRequests&aaroute=7').routeId).toBeUndefined();
|
expect(parseRoute('?aatab=manageRequests&aaroute=7').routeId).toBeUndefined();
|
||||||
@@ -78,14 +108,14 @@ describe('parseRoute', () => {
|
|||||||
|
|
||||||
describe('navigate', () => {
|
describe('navigate', () => {
|
||||||
it('после navigate() параметры читаются обратно тем же parseRoute (round-trip)', () => {
|
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);
|
const roundTripped = parseRoute(window.location.search);
|
||||||
expect(roundTripped.tab).toBe('manageRequests');
|
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 полностью убирает параметры вкладки из адреса', () => {
|
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' });
|
navigate({ tab: 'dashboard' });
|
||||||
expect(window.location.search).toBe('');
|
expect(window.location.search).toBe('');
|
||||||
});
|
});
|
||||||
@@ -96,6 +126,13 @@ describe('navigate', () => {
|
|||||||
expect(window.history.length).toBe(initialLength + 1);
|
expect(window.history.length).toBe(initialLength + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trip для manageByUser (оба сотрудника)', () => {
|
||||||
|
navigate({ tab: 'manageByUser', manageByUser: { userEmail: 'ivanov@tnsu.kz', targetEmail: 'petrov@tnsu.kz' } });
|
||||||
|
const roundTripped = parseRoute(window.location.search);
|
||||||
|
expect(roundTripped.tab).toBe('manageByUser');
|
||||||
|
expect(roundTripped.manageByUser).toEqual({ userEmail: 'ivanov@tnsu.kz', targetEmail: 'petrov@tnsu.kz' });
|
||||||
|
});
|
||||||
|
|
||||||
it('round-trip для resolve.departmentId', () => {
|
it('round-trip для resolve.departmentId', () => {
|
||||||
navigate({
|
navigate({
|
||||||
tab: 'resolve',
|
tab: 'resolve',
|
||||||
|
|||||||
@@ -33,9 +33,29 @@ export interface IResolveRouteParams {
|
|||||||
departmentId: string;
|
departmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IManageByUserRouteParams {
|
||||||
|
/** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */
|
||||||
|
userEmail: string;
|
||||||
|
/**
|
||||||
|
* На кого заменять. Часть ссылки, а не только состояние экрана: замена обычно
|
||||||
|
* идёт в несколько приёмов (сначала заявки, потом маршруты, потом что-то
|
||||||
|
* отдельной строкой), и выбранного «получателя» незачем терять между ними.
|
||||||
|
* Пусто — получатель ещё не выбран.
|
||||||
|
*/
|
||||||
|
targetEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IManageRequestRouteParams {
|
export interface IManageRequestRouteParams {
|
||||||
requestTypeId: string;
|
requestTypeId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
/**
|
||||||
|
* Партиция (список-архив данных), где физически лежит заявка — у разных
|
||||||
|
* партиций независимая нумерация Id (см. ТЗ по архивации), поэтому без
|
||||||
|
* listId requestId неоднозначен. Пусто — старая ссылка (до архивации) или
|
||||||
|
* ссылка ещё не резолвнута; тогда экран считает, что заявка в текущей
|
||||||
|
* партиции (см. ManageRequestsScreen).
|
||||||
|
*/
|
||||||
|
listId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAdminRoute {
|
export interface IAdminRoute {
|
||||||
@@ -53,18 +73,21 @@ export interface IAdminRoute {
|
|||||||
resolve?: IResolveRouteParams;
|
resolve?: IResolveRouteParams;
|
||||||
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
||||||
manage?: IManageRequestRouteParams;
|
manage?: IManageRequestRouteParams;
|
||||||
|
/** Задан только при tab='manageByUser' и непустом userEmail. */
|
||||||
|
manageByUser?: IManageByUserRouteParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
||||||
|
|
||||||
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
||||||
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
||||||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
|
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
|
||||||
|
|
||||||
const PARAM_KEYS = [
|
const PARAM_KEYS = [
|
||||||
'aatab', 'aaroute', 'aarfilter',
|
'aatab', 'aaroute', 'aarfilter',
|
||||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||||
'aamtype', 'aamreq'
|
'aamtype', 'aamreq', 'aamlist',
|
||||||
|
'aabuser', 'aabtarget'
|
||||||
];
|
];
|
||||||
|
|
||||||
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
// 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 manageReqId = params.get('aamreq');
|
||||||
const manage: IManageRequestRouteParams | undefined =
|
const manage: IManageRequestRouteParams | undefined =
|
||||||
tab === 'manageRequests' && manageTypeId && manageReqId
|
tab === 'manageRequests' && manageTypeId && manageReqId
|
||||||
? { requestTypeId: manageTypeId, requestId: manageReqId }
|
? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const byUserEmail = tab === 'manageByUser' ? params.get('aabuser') : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tab,
|
tab,
|
||||||
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
||||||
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
||||||
resolve,
|
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) {
|
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
|
||||||
params.set('aamtype', route.manage.requestTypeId);
|
params.set('aamtype', route.manage.requestTypeId);
|
||||||
params.set('aamreq', route.manage.requestId);
|
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();
|
const query = params.toString();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PatchRequestTypeInput,
|
PatchRequestTypeInput,
|
||||||
PatchTemplateRouteInput,
|
PatchTemplateRouteInput,
|
||||||
PatchTemplateRouteStepInput,
|
PatchTemplateRouteStepInput,
|
||||||
|
RequestListPartition,
|
||||||
RequestType,
|
RequestType,
|
||||||
RequestTypeListSeries,
|
RequestTypeListSeries,
|
||||||
ResolveTemplateRouteQuery,
|
ResolveTemplateRouteQuery,
|
||||||
@@ -47,7 +48,13 @@ export class ApprovalRouteApi {
|
|||||||
// портала, которому нужен весь список адресов разом, а не по одному запросу
|
// портала, которому нужен весь список адресов разом, а не по одному запросу
|
||||||
// на тип.
|
// на тип.
|
||||||
listsAll: (): Promise<RequestTypeListSeries[]> =>
|
listsAll: (): Promise<RequestTypeListSeries[]> =>
|
||||||
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists')
|
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists'),
|
||||||
|
|
||||||
|
// Вся серия партиций ОДНОГО типа заявки (архив + текущая) — нужна экрану
|
||||||
|
// редактирования заявок: заявка может лежать в любой из них, не только в
|
||||||
|
// текущей (см. requestManageService.ts).
|
||||||
|
lists: (requestTypeId: number): Promise<RequestListPartition[]> =>
|
||||||
|
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
|
||||||
};
|
};
|
||||||
|
|
||||||
public readonly templateRoutes = {
|
public readonly templateRoutes = {
|
||||||
@@ -92,6 +99,12 @@ export class ApprovalRouteApi {
|
|||||||
this.http.patch<TemplateRouteStep>(`/api/template-routes/${routeId}/steps/${id}`, body),
|
this.http.patch<TemplateRouteStep>(`/api/template-routes/${routeId}/steps/${id}`, body),
|
||||||
|
|
||||||
remove: (routeId: number, id: number): Promise<void> =>
|
remove: (routeId: number, id: number): Promise<void> =>
|
||||||
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<TemplateRouteStep[]> =>
|
||||||
|
this.http.get<TemplateRouteStep[]>('/api/template-route-steps', { executorEmail })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import '@pnp/sp/lists';
|
|||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
import '@pnp/sp/search';
|
import '@pnp/sp/search';
|
||||||
|
// sp.batched() — findActiveApprovalRowsForApprover читает все партиции ОДНОГО
|
||||||
|
// сайта одним HTTP-запросом (см. метод ниже). Импорт side-effect'ный: без него
|
||||||
|
// метод есть в типах, но в рантайме не зарегистрирован.
|
||||||
|
import '@pnp/sp/batching';
|
||||||
import { AadHttpClient } from '@microsoft/sp-http';
|
import { AadHttpClient } from '@microsoft/sp-http';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +16,9 @@ import {
|
|||||||
ManagedRequestMatrix,
|
ManagedRequestMatrix,
|
||||||
ManagedRequestPage,
|
ManagedRequestPage,
|
||||||
ManagedRequestSummary,
|
ManagedRequestSummary,
|
||||||
RequestType
|
RequestListPartition,
|
||||||
|
RequestType,
|
||||||
|
UserApprovalRow
|
||||||
} from '../models';
|
} from '../models';
|
||||||
|
|
||||||
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
||||||
@@ -86,15 +92,19 @@ interface IRawDataRow {
|
|||||||
RequestStatus?: string;
|
RequestStatus?: string;
|
||||||
Created?: string;
|
Created?: string;
|
||||||
Author?: { Title?: 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 {
|
return {
|
||||||
id: r.Id,
|
id: r.Id,
|
||||||
title: r.Title || `#${r.Id}`,
|
title: r.Title || `#${r.Id}`,
|
||||||
status: r.RequestStatus || '',
|
status: r.RequestStatus || '',
|
||||||
created: r.Created || '',
|
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 };
|
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 — просто последние без
|
* постранично, для комбобокса выбора заявки. Сначала Search по названию
|
||||||
* фильтра), затем дочитывание полей REST'ом по найденным Id — Search не
|
* (свободный терм, пустой query — просто последние без фильтра), затем
|
||||||
* отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в
|
* дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
|
||||||
* request-portal).
|
* поля списка надёжно (та же двухшаговая схема, что в request-portal).
|
||||||
|
*
|
||||||
|
* Заявки, набранные ростом одного типа, физически «переезжают» на новый
|
||||||
|
* список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
|
||||||
|
* заявки из архивных партиций в комбобоксе просто не найти.
|
||||||
*/
|
*/
|
||||||
public async searchRequests(
|
public async searchRequests(
|
||||||
requestType: RequestType,
|
partitions: RequestListPartition[],
|
||||||
|
siteUrl: string,
|
||||||
query: string,
|
query: string,
|
||||||
page: number,
|
page: number,
|
||||||
pageSize = 20
|
pageSize = 20
|
||||||
): Promise<ManagedRequestPage> {
|
): Promise<ManagedRequestPage> {
|
||||||
if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false };
|
if (partitions.length === 0) return { items: [], hasMore: false };
|
||||||
|
|
||||||
const sp = this.clientFor(requestType.siteUrl);
|
const sp = this.clientFor(siteUrl);
|
||||||
const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
|
const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`);
|
||||||
const clauses = [`Path:"${absUrl}*"`];
|
const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0];
|
||||||
|
const clauses = [pathClause];
|
||||||
const text = freeTextClause(query);
|
const text = freeTextClause(query);
|
||||||
if (text) clauses.push(text);
|
if (text) clauses.push(text);
|
||||||
|
|
||||||
@@ -154,65 +181,83 @@ export class RequestManageService {
|
|||||||
SortList: [{ Property: 'Created', Direction: 1 }]
|
SortList: [{ Property: 'Created', Direction: 1 }]
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
|
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>;
|
||||||
// Дедуп по Id обязателен: TrimDuplicates: false выше выключает штатную
|
// Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных
|
||||||
// дедупликацию Search (она иногда схлопывает нужные нам заявки), и тогда
|
// партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому
|
||||||
// индекс может отдать один и тот же элемент несколькими хитами
|
// одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates:
|
||||||
// (переиндексация, несколько «версий» в crawl). Без этого фильтра хиты
|
// false выше выключает штатную дедупликацию Search (она иногда схлопывает
|
||||||
// превращались в пункты комбобокса один-в-один, и заявка показывалась в
|
// нужные нам заявки), и тогда индекс может отдать один и тот же элемент
|
||||||
// списке дважды — с одинаковым React-ключом. Та же причина и то же лечение,
|
// несколькими хитами (переиндексация, несколько «версий» в crawl) — та же
|
||||||
// что в request-portal (features/myRequests/myRequests.service.ts/hitsToEntries).
|
// причина и то же лечение, что в request-portal
|
||||||
const seen = new Set<number>();
|
// (features/myRequests/myRequests.service.ts/hitsToEntries).
|
||||||
const ids = rawRows
|
const seen = new Set<string>();
|
||||||
.map(r => Number(r.ListItemID))
|
const hits: Array<{ id: number; partition: RequestListPartition }> = [];
|
||||||
.filter(id => !isNaN(id) && id > 0)
|
rawRows.forEach(r => {
|
||||||
.filter(id => {
|
const id = Number(r.ListItemID);
|
||||||
if (seen.has(id)) return false;
|
if (isNaN(id) || id <= 0 || !r.Path) return;
|
||||||
seen.add(id);
|
const partition = this.partitionForPath(siteUrl, partitions, r.Path);
|
||||||
return true;
|
if (!partition) return;
|
||||||
});
|
const key = `${partition.listId}|${id}`;
|
||||||
if (ids.length === 0) return { items: [], hasMore: false };
|
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 idsByListId = new Map<string, number[]>();
|
||||||
const dataRows = (await list.items
|
hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
|
||||||
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
|
|
||||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title')
|
const rowByKey = new Map<string, IRawDataRow>();
|
||||||
.expand('Author')
|
await Promise.all(
|
||||||
.top(ids.length)()) as IRawDataRow[];
|
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.
|
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
|
||||||
const items = ids
|
const items = hits
|
||||||
.map(id => byId.get(id))
|
.map(h => {
|
||||||
.filter((r): r is IRawDataRow => !!r)
|
const row = rowByKey.get(`${h.partition.listId}|${h.id}`);
|
||||||
.map(toSummary);
|
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;
|
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
|
||||||
return { items, hasMore: page * pageSize < totalRows };
|
return { items, hasMore: page * pageSize < totalRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. */
|
/**
|
||||||
public async getMatrix(requestType: RequestType, requestId: number): Promise<ManagedRequestMatrix> {
|
* Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
|
||||||
if (!requestType.listId || !requestType.siteUrl) {
|
*
|
||||||
throw new Error('У типа заявки не указан список SharePoint.');
|
* listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из
|
||||||
}
|
* ManagedRequestSummary.listId, отданного searchRequests/предыдущей
|
||||||
const sp = this.clientFor(requestType.siteUrl);
|
* getMatrix) — не requestType.listId: та партиция может быть уже не той, где
|
||||||
const dataList = sp.web.getList(requestType.listId);
|
* лежит эта заявка (см. ТЗ по архивации).
|
||||||
|
*/
|
||||||
|
public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise<ManagedRequestMatrix> {
|
||||||
|
const sp = this.clientFor(siteUrl);
|
||||||
|
const dataList = sp.web.getList(listId);
|
||||||
|
|
||||||
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
|
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
|
||||||
dataList.items
|
dataList.items
|
||||||
.getById(requestId)
|
.getById(requestId)
|
||||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title')
|
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
|
||||||
.expand('Author')() as Promise<IRawDataRow>,
|
.expand('Author')() as Promise<IRawDataRow>,
|
||||||
this.listTitles(sp, requestType.listId)
|
this.listTitles(sp, listId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
|
const dataListUrl = listAbsUrl(siteUrl, listId);
|
||||||
|
|
||||||
if (!approvalListTitle) {
|
if (!approvalListTitle) {
|
||||||
return {
|
return {
|
||||||
request: toSummary(requestRow),
|
request: toSummary(requestRow, listId),
|
||||||
siteUrl: requestType.siteUrl,
|
siteUrl,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListUrl,
|
dataListUrl,
|
||||||
approvalListTitle: '',
|
approvalListTitle: '',
|
||||||
@@ -241,30 +286,37 @@ export class RequestManageService {
|
|||||||
.expand(FIELDS.approver)
|
.expand(FIELDS.approver)
|
||||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => {
|
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => this.toApprovalRow(r, requestId, 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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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<string, unknown>, 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). */
|
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
|
||||||
@@ -274,6 +326,196 @@ export class RequestManageService {
|
|||||||
return user.Id;
|
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<number, RequestListPartition[]>,
|
||||||
|
approverEmail: string,
|
||||||
|
activeStatuses: string[]
|
||||||
|
): Promise<UserApprovalRow[]> {
|
||||||
|
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<string, ITarget[]>();
|
||||||
|
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<string, Array<Record<string, unknown>>>();
|
||||||
|
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<Record<string, unknown>>); })
|
||||||
|
.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<string, Set<number>>();
|
||||||
|
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<number>();
|
||||||
|
set.add(ticketId);
|
||||||
|
ticketIdsByList.set(t.dataListId, set);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataRowsByList = new Map<string, IRawDataRow[]>();
|
||||||
|
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<UserApprovalRow, 'siteUrl' | 'approvalListTitle' | 'ticketId' | 'phase' | 'id'>,
|
||||||
|
email: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<Record<string, unknown>>;
|
||||||
|
|
||||||
|
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) — сервис это не перепроверяет. */
|
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
|
||||||
public async reassignApprover(
|
public async reassignApprover(
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ export const texts = {
|
|||||||
requestTypes: 'Типы заявок',
|
requestTypes: 'Типы заявок',
|
||||||
routes: 'Маршруты',
|
routes: 'Маршруты',
|
||||||
resolve: 'Проверка маршрута',
|
resolve: 'Проверка маршрута',
|
||||||
manageRequests: 'Редактирование заявок'
|
manageRequests: 'Редактирование заявок',
|
||||||
|
manageByUser: 'Заявки по участнику'
|
||||||
},
|
},
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
@@ -106,6 +107,10 @@ export const texts = {
|
|||||||
manageRequestsCard: {
|
manageRequestsCard: {
|
||||||
title: 'Редактирование заявок',
|
title: 'Редактирование заявок',
|
||||||
description: 'Правка маршрута согласования конкретной заявки'
|
description: 'Правка маршрута согласования конкретной заявки'
|
||||||
|
},
|
||||||
|
manageByUserCard: {
|
||||||
|
title: 'Заявки по участнику',
|
||||||
|
description: 'Массовая замена согласующего в заявках и шаблонах маршрутов'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -347,7 +352,7 @@ export const texts = {
|
|||||||
projectSearchPlaceholder: 'Поиск проекта по названию...',
|
projectSearchPlaceholder: 'Поиск проекта по названию...',
|
||||||
projectClear: 'Без проекта',
|
projectClear: 'Без проекта',
|
||||||
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
||||||
departmentLabel: 'Отдел (для шага «Руководитель указанного отдела»)',
|
departmentLabel: 'Отдел',
|
||||||
departmentHint: 'Заполните, если в маршруте есть шаг с типом руководителя «Руководитель указанного отдела».',
|
departmentHint: 'Заполните, если в маршруте есть шаг с типом руководителя «Руководитель указанного отдела».',
|
||||||
departmentPlaceholder: 'Не выбран',
|
departmentPlaceholder: 'Не выбран',
|
||||||
departmentSearchPlaceholder: 'Поиск по названию отдела...',
|
departmentSearchPlaceholder: 'Поиск по названию отдела...',
|
||||||
@@ -440,6 +445,65 @@ export const texts = {
|
|||||||
saveError: 'Не удалось сохранить'
|
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: {
|
select: {
|
||||||
placeholder: 'Выберите значение',
|
placeholder: 'Выберите значение',
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { mapWithLimit } from './parallel';
|
||||||
|
|
||||||
|
describe('mapWithLimit', () => {
|
||||||
|
it('возвращает результаты в порядке входного массива, а не в порядке завершения', async () => {
|
||||||
|
// Первый элемент заведомо медленнее второго — если бы порядок задавало
|
||||||
|
// завершение, они бы поменялись местами.
|
||||||
|
const settled = await mapWithLimit(
|
||||||
|
[30, 0, 10],
|
||||||
|
delay => new Promise<number>(resolve => setTimeout(() => resolve(delay), delay)),
|
||||||
|
{ limit: 3 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settled.map(s => (s.ok ? s.value : 'err'))).toEqual([30, 0, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отказ одного элемента не прерывает остальные', async () => {
|
||||||
|
const settled = await mapWithLimit([1, 2, 3], n =>
|
||||||
|
n === 2 ? Promise.reject(new Error('bang')) : Promise.resolve(n)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settled[0]).toEqual({ ok: true, value: 1 });
|
||||||
|
expect(settled[1].ok).toBe(false);
|
||||||
|
expect(settled[2]).toEqual({ ok: true, value: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не превышает предел одновременных запросов', async () => {
|
||||||
|
let running = 0;
|
||||||
|
let peak = 0;
|
||||||
|
|
||||||
|
await mapWithLimit(
|
||||||
|
[1, 2, 3, 4, 5, 6, 7],
|
||||||
|
() => {
|
||||||
|
running += 1;
|
||||||
|
peak = Math.max(peak, running);
|
||||||
|
return new Promise<void>(resolve =>
|
||||||
|
setTimeout(() => {
|
||||||
|
running -= 1;
|
||||||
|
resolve();
|
||||||
|
}, 5)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ limit: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(peak).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onSettled вызывается по разу на каждый элемент, включая упавшие', async () => {
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
await mapWithLimit([1, 2, 3], n => (n === 2 ? Promise.reject(new Error('bang')) : Promise.resolve(n)), {
|
||||||
|
onSettled: () => {
|
||||||
|
done += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(done).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустой список — пустой результат, fn не вызывается', async () => {
|
||||||
|
const fn = jest.fn();
|
||||||
|
const settled = await mapWithLimit([], fn);
|
||||||
|
|
||||||
|
expect(settled).toEqual([]);
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Параллельный запуск с ограничением одновременных запросов и результатом
|
||||||
|
* «по каждому элементу отдельно».
|
||||||
|
*
|
||||||
|
* Зачем свой велосипед вместо Promise.allSettled: его нет в целевом lib этого
|
||||||
|
* проекта (tsconfig.json — es5/es2015.*, без es2020.promise). Плюс здесь нужен
|
||||||
|
* не только «не падать целиком», но и ПОТОЛОК одновременных запросов: без него
|
||||||
|
* пакетная замена на сотню строк уходит в SharePoint сотней параллельных
|
||||||
|
* запросов и упирается в его троттлинг (429), после чего часть строк не
|
||||||
|
* записывается — то есть «быстрее» превращается в «половина не сохранилась».
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SettledResult<R> = { ok: true; value: R } | { ok: false; error: unknown };
|
||||||
|
|
||||||
|
/** Сколько запросов держим в воздухе по умолчанию — компромисс между скоростью и троттлингом SharePoint. */
|
||||||
|
export const DEFAULT_CONCURRENCY = 5;
|
||||||
|
|
||||||
|
export interface IMapWithLimitOptions {
|
||||||
|
limit?: number;
|
||||||
|
/** Вызывается после завершения КАЖДОГО элемента — для полосы прогресса. */
|
||||||
|
onSettled?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пропускает items через fn, держа не больше limit запросов одновременно.
|
||||||
|
* Результат — в том же порядке, что items; отказ одного элемента не прерывает
|
||||||
|
* остальные (см. SettledResult).
|
||||||
|
*/
|
||||||
|
export async function mapWithLimit<T, R>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T, index: number) => Promise<R>,
|
||||||
|
options: IMapWithLimitOptions = {}
|
||||||
|
): Promise<Array<SettledResult<R>>> {
|
||||||
|
const limit = Math.max(1, options.limit ?? DEFAULT_CONCURRENCY);
|
||||||
|
const results: Array<SettledResult<R>> = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
|
||||||
|
// Ровно limit «рабочих», каждый в цикле забирает следующий свободный индекс.
|
||||||
|
// Так предел соблюдается точно, без пауз между пачками (в отличие от разбиения
|
||||||
|
// на чанки, где приходится ждать самый медленный элемент каждой пачки).
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
for (;;) {
|
||||||
|
const index = next;
|
||||||
|
next += 1;
|
||||||
|
if (index >= items.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
results[index] = { ok: true, value: await fn(items[index], index) };
|
||||||
|
} catch (error) {
|
||||||
|
results[index] = { ok: false, error };
|
||||||
|
}
|
||||||
|
if (options.onSettled) options.onSettled();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(new Array(Math.min(limit, items.length)).fill(0).map(worker));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user