первый вариант
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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/ui-kit Sidebar), что и в request-portal — единый
|
||||||
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
||||||
@@ -27,7 +28,8 @@ const NAV_ITEMS: ISidebarItem[] = [
|
|||||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||||
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||||
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' }
|
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||||
|
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function sidebarActiveId(tab: ScreenKey): string {
|
function sidebarActiveId(tab: ScreenKey): string {
|
||||||
@@ -150,6 +152,9 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
{route.tab === 'manageRequests' && (
|
{route.tab === 'manageRequests' && (
|
||||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||||
)}
|
)}
|
||||||
|
{route.tab === 'manageByUser' && (
|
||||||
|
<ManageByUserScreen api={api} dictionary={dictionary} requestManage={requestManage} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests';
|
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser';
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Employee } from '../../models';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { Field, FormGrid, FormModal, SearchableSelect } from '../ui';
|
||||||
|
|
||||||
|
export interface IManageByUserReplaceModalProps {
|
||||||
|
title: string;
|
||||||
|
employees: Employee[];
|
||||||
|
employeesLoading: boolean;
|
||||||
|
onSave: (email: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор НОВОГО согласующего/исполнителя для замены (точечной или массовой) —
|
||||||
|
* урезанная версия ManageRequestsApproverModal: здесь нет SLA-полей (это не
|
||||||
|
* правка конкретного шага заявки, а массовая подмена человека), должность
|
||||||
|
* резолвится вызывающей стороной (см. ManageByUserScreen) там, где она вообще
|
||||||
|
* нужна — для живых заявок, не для шаблонов (там ExecutorEmail/Position
|
||||||
|
* взаимоисключающие, трогать Position нельзя).
|
||||||
|
*/
|
||||||
|
export const ManageByUserReplaceModal: React.FC<IManageByUserReplaceModalProps> = ({
|
||||||
|
title,
|
||||||
|
employees,
|
||||||
|
employeesLoading,
|
||||||
|
onSave,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [email, setEmail] = React.useState('');
|
||||||
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
|
const handleSubmit = (): void => {
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
if (!trimmedEmail) {
|
||||||
|
submit.fail(texts.manageByUser.validationEmployee);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submit.run(() => onSave(trimmedEmail));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}>
|
||||||
|
<FormGrid>
|
||||||
|
<Field label={texts.manageByUser.newEmployeeLabel} required full>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employees.map(toEmployeeOption)}
|
||||||
|
value={email}
|
||||||
|
loading={employeesLoading}
|
||||||
|
placeholder={texts.manageByUser.newEmployeePlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={setEmail}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ManageByUserScreen.module.scss';
|
||||||
|
import { ManageByUserReplaceModal } from './ManageByUserReplaceModal';
|
||||||
|
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 { 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,
|
||||||
|
SearchableSelect,
|
||||||
|
Toast
|
||||||
|
} from '../ui';
|
||||||
|
|
||||||
|
export interface IManageByUserScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
|
requestManage: RequestManageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Активные статусы строки для этого экрана — сознательно уже, чем
|
||||||
|
// isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и
|
||||||
|
// черновики сюда не входят (согласовано отдельно, см. план реализации).
|
||||||
|
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||||
|
|
||||||
|
type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
||||||
|
|
||||||
|
// Promise.allSettled недоступен в целевом lib этого проекта (tsconfig.json —
|
||||||
|
// es5/es2015.*, без es2020.promise) — свой аналог вместо него: массовая замена
|
||||||
|
// не должна падать целиком из-за одной сломанной строки/шага (см.
|
||||||
|
// bulkReplaceLive/bulkReplaceTemplates ниже).
|
||||||
|
function settle<T>(p: Promise<T>): Promise<Settled<T>> {
|
||||||
|
return p.then((value): Settled<T> => ({ ok: true, value })).catch((error: unknown): Settled<T> => ({ ok: false, error }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Та же цветовая группировка статуса заявки, что и в 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 }) => {
|
||||||
|
const requestTypes = useRequestTypes(api);
|
||||||
|
const listSeries = useRequestTypeListsAll(api);
|
||||||
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
const dictionaries = usePortalDictionaries(dictionary);
|
||||||
|
|
||||||
|
const [employeeEmail, setEmployeeEmail] = React.useState('');
|
||||||
|
|
||||||
|
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 [replaceScope, setReplaceScope] = React.useState<IReplaceScope | undefined>();
|
||||||
|
const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
|
||||||
|
|
||||||
|
async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise<{ ok: number; fail: number }> {
|
||||||
|
const settled = await Promise.all(rows.map(r => settle(requestManage.reassignApprover(r, r.id, email, position))));
|
||||||
|
const ok = settled.filter(s => s.ok).length;
|
||||||
|
return { ok, fail: settled.length - ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkReplaceTemplates(
|
||||||
|
rows: UserTemplateStepRow[],
|
||||||
|
email: string
|
||||||
|
): Promise<{ ok: number; fail: number; conflicts: string[] }> {
|
||||||
|
const settled = await Promise.all(
|
||||||
|
rows.map(r => settle(api.templateRouteSteps.patch(r.routeId, r.id, { executorEmail: email })))
|
||||||
|
);
|
||||||
|
let ok = 0;
|
||||||
|
const conflicts: string[] = [];
|
||||||
|
settled.forEach((s, i) => {
|
||||||
|
if (s.ok) {
|
||||||
|
ok += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reason = s.error as ApiClientError;
|
||||||
|
if (reason?.status === 409) {
|
||||||
|
const row = rows[i];
|
||||||
|
conflicts.push(texts.manageByUser.templateConflict(`${row.requestTypeName} — ${row.companyName}`, row.orderNo));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { ok, fail: settled.length - ok, conflicts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReplaceConfirm = async (email: string): Promise<void> => {
|
||||||
|
if (!replaceScope) return;
|
||||||
|
const position = findEmployeeByEmail(employees.items, email)?.position || '';
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
if (replaceScope.live.length > 0) {
|
||||||
|
const { ok, fail } = await bulkReplaceLive(replaceScope.live, email, position);
|
||||||
|
messages.push(texts.manageByUser.liveResult(ok, fail));
|
||||||
|
}
|
||||||
|
if (replaceScope.templates.length > 0) {
|
||||||
|
const { ok, fail, conflicts } = await bulkReplaceTemplates(replaceScope.templates, email);
|
||||||
|
messages.push(texts.manageByUser.templatesResult(ok, fail));
|
||||||
|
messages.push(...conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResultToast({ message: messages.join(' '), kind: 'ok' });
|
||||||
|
setReplaceScope(undefined);
|
||||||
|
setCheckedLiveKeys(new Set());
|
||||||
|
setCheckedTemplateIds(new Set());
|
||||||
|
reloadLiveRows();
|
||||||
|
templateSteps.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
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} onClick={() => setReplaceScope({ 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} onClick={() => setReplaceScope({ 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={!employeeEmail || !hasAnyRows}
|
||||||
|
onClick={() => setReplaceScope({ live: liveRows, templates: templateRows })}
|
||||||
|
>
|
||||||
|
{texts.manageByUser.replaceEverywhere}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<FormGrid>
|
||||||
|
<Field label={texts.manageByUser.employeeLabel} required hint={texts.manageByUser.employeeHint} full>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employees.items.map(toEmployeeOption)}
|
||||||
|
value={employeeEmail}
|
||||||
|
loading={employees.isLoading}
|
||||||
|
placeholder={texts.manageByUser.employeePlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={setEmployeeEmail}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{employeeEmail && (
|
||||||
|
<Panel>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div className={styles.panelTitle}>{`${texts.manageByUser.liveTitle} (${liveRows.length})`}</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={checkedLiveRows.length === 0}
|
||||||
|
onClick={() => setReplaceScope({ live: checkedLiveRows, templates: [] })}
|
||||||
|
>
|
||||||
|
{texts.manageByUser.replaceSelected(checkedLiveRows.length)}
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={checkedTemplateRows.length === 0}
|
||||||
|
onClick={() => setReplaceScope({ live: [], templates: checkedTemplateRows })}
|
||||||
|
>
|
||||||
|
{texts.manageByUser.replaceSelected(checkedTemplateRows.length)}
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{replaceScope && (
|
||||||
|
<ManageByUserReplaceModal
|
||||||
|
title={texts.manageByUser.replaceModalTitle}
|
||||||
|
employees={employees.items}
|
||||||
|
employeesLoading={employees.isLoading}
|
||||||
|
onSave={handleReplaceConfirm}
|
||||||
|
onClose={() => setReplaceScope(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resultToast && (
|
||||||
|
<Toast message={resultToast.message} kind={resultToast.kind} onClose={() => setResultToast(undefined)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ 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',
|
||||||
|
|||||||
@@ -99,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,
|
||||||
RequestListPartition
|
RequestListPartition,
|
||||||
|
RequestType,
|
||||||
|
UserApprovalRow
|
||||||
} from '../models';
|
} from '../models';
|
||||||
|
|
||||||
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
||||||
@@ -280,32 +286,39 @@ 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, listId), 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). */
|
||||||
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
|
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
|
||||||
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
||||||
@@ -313,6 +326,166 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
|
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
|
||||||
* проверить 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: {
|
||||||
@@ -440,6 +441,42 @@ 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: 'Заменить',
|
||||||
|
replaceSelected: (count: number): string => `Заменить выбранные (${count})`,
|
||||||
|
replaceModalTitle: 'Новый согласующий',
|
||||||
|
newEmployeeLabel: 'Сотрудник',
|
||||||
|
newEmployeePlaceholder: 'Выберите нового согласующего',
|
||||||
|
validationEmployee: 'Выберите сотрудника.',
|
||||||
|
liveResult: (ok: number, fail: number): string =>
|
||||||
|
fail > 0 ? `Заявки: заменено ${ok}, не удалось ${fail}.` : `Заявки: заменено ${ok}.`,
|
||||||
|
templatesResult: (ok: number, fail: number): string =>
|
||||||
|
fail > 0 ? `Шаблоны: заменено ${ok}, не удалось ${fail}.` : `Шаблоны: заменено ${ok}.`,
|
||||||
|
templateConflict: (routeLabel: string, orderNo: number): string =>
|
||||||
|
`В маршруте «${routeLabel}» уже есть шаг с этим исполнителем — шаг №${orderNo} не изменён.`
|
||||||
|
},
|
||||||
|
|
||||||
// Значения по умолчанию для переиспользуемых контролов выбора.
|
// Значения по умолчанию для переиспользуемых контролов выбора.
|
||||||
select: {
|
select: {
|
||||||
placeholder: 'Выберите значение',
|
placeholder: 'Выберите значение',
|
||||||
|
|||||||
Reference in New Issue
Block a user