diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepSearchController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepSearchController.cs new file mode 100644 index 0000000..e93f3b6 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepSearchController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +/// +/// Поиск шагов шаблонов маршрутов НЕ по конкретному маршруту, а сквозной, по всем +/// маршрутам разом — отдельный top-level контроллер, а не вложенный под +/// /api/template-routes/{routeId}/steps (тот же приём, что RequestTypeListsController +/// отдельно от RequestTypesController). +/// +[ApiController] +[Authorize] +[Route("api/template-route-steps")] +[Produces("application/json")] +public sealed class TemplateRouteStepSearchController(TemplateRouteStepService service) : ControllerBase +{ + /// Шаги ВСЕХ маршрутов с данным ExecutorEmail — для экрана «заменить согласующего everywhere». + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task List([FromQuery] string executorEmail, CancellationToken ct) => + (await service.ListByExecutorEmailAsync(executorEmail, ct)).ToActionResult(); +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 5c08ecc..0d29747 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -40,20 +40,34 @@ public sealed class ReminderService( .Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); - // Типы независимы (разные сайты/списки в SharePoint) — их обход - // распараллелен, иначе время растёт линейно с числом типов: каждый тип - // сам по себе делает несколько последовательных HTTP-запросов. - var results = await Task.WhenAll(types.Select(type => GetOverdueForTypeSafeAsync(type, now, cancellationToken))); + // Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним DbContext — он не + // потокобезопасен, и параллельный вызов GetArchivedAsync из нескольких + // веток Task.WhenAll ниже валит его исключением ("A second operation + // was started on this context instance before a previous operation + // completed"), которое try/catch в GetOverdueForTypeSafeAsync тихо + // проглатывает — часть напоминаний молча пропадала бы без всякой + // видимой ошибки. Сама выборка — без HTTP, быстрая, последовательность + // здесь не стоит ощутимого времени. + var archivedByType = new Dictionary>(); + foreach (var type in types) + archivedByType[type.Id] = await partitionRegistry.GetArchivedAsync(type, cancellationToken); + + // Дальше типы независимы (разные сайты/списки в SharePoint, БД уже не + // трогают) — обход распараллелен, иначе время растёт линейно с числом + // типов: каждый тип сам по себе делает несколько последовательных + // HTTP-запросов. + var results = await Task.WhenAll( + types.Select(type => GetOverdueForTypeSafeAsync(type, archivedByType[type.Id], now, cancellationToken))); return Result.Ok>(results.SelectMany(r => r).ToList()); } private async Task> GetOverdueForTypeSafeAsync( - RequestType type, DateTimeOffset now, CancellationToken cancellationToken) + RequestType type, IReadOnlyList archived, DateTimeOffset now, CancellationToken cancellationToken) { try { - return await GetOverdueForTypeAsync(type, now, cancellationToken); + return await GetOverdueForTypeAsync(type, archived, now, cancellationToken); } catch (Exception e) { @@ -70,14 +84,13 @@ public sealed class ReminderService( /// списке, и не должна пропадать из напоминаний только из-за того, что для /// новых заявок завели следующий список. /// - /// request_list_partitions хранит только архив (см. ListPartitionRegistry) — - /// текущая партиция синтезируется отдельно и добавляется к архиву здесь же, - /// а не читается из БД. + /// archived — уже прочитан вызывающей стороной (GetOverdueAsync, + /// последовательно для всех типов разом) — здесь БД больше не трогаем, + /// только HTTP к SharePoint, поэтому вызов можно параллелить по типам. /// private async Task> GetOverdueForTypeAsync( - RequestType type, DateTimeOffset now, CancellationToken cancellationToken) + RequestType type, IReadOnlyList archived, DateTimeOffset now, CancellationToken cancellationToken) { - var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); var partitions = archived diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index b2b3654..65d87d4 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -25,6 +25,29 @@ public sealed class TemplateRouteStepService( return Result.Ok>(items); } + /// + /// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для + /// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen): + /// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не + /// попадают — это не привязка к конкретному человеку. + /// + public async Task>> ListByExecutorEmailAsync( + string executorEmail, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(executorEmail)) + return Result.Ok>(Array.Empty()); + + var items = await db.TemplateRouteSteps.AsNoTracking() + .Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail)) + .OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo) + .Select(x => new TemplateRouteStepDto( + x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, + x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + public async Task> GetAsync(int routeId, int id, CancellationToken cancellationToken) { var entity = await db.TemplateRouteSteps.AsNoTracking() diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 05547da..ab4241e 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -511,4 +511,44 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture x.RouteId == routeAId || x.RouteId == routeBId); + } + + [Fact(DisplayName = "ListByExecutorEmailAsync с пустым email возвращает пустой список, а не ошибку")] + public async Task ListByExecutorEmailAsync_EmptyEmail_ReturnsEmptyList() + { + // Action + var result = await _service.ListByExecutorEmailAsync(string.Empty, CancellationToken.None); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeEmpty(); + } } diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index fe98a59..52b6bf5 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -17,6 +17,7 @@ import { RoutesScreen } from '../screens/RoutesScreen'; import { RouteStepsScreen } from '../screens/RouteStepsScreen'; import { ResolveScreen } from '../screens/ResolveScreen'; import { ManageRequestsScreen } from '../screens/ManageRequestsScreen'; +import { ManageByUserScreen } from '../screens/ManageByUserScreen'; // Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый // стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не @@ -27,7 +28,8 @@ const NAV_ITEMS: ISidebarItem[] = [ { 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: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' }, + { id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' } ]; function sidebarActiveId(tab: ScreenKey): string { @@ -150,6 +152,9 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint {route.tab === 'manageRequests' && ( )} + {route.tab === 'manageByUser' && ( + + )} diff --git a/frontend/src/webparts/approvalAdmin/components/app/types.ts b/frontend/src/webparts/approvalAdmin/components/app/types.ts index d6b53d8..71b47cb 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/types.ts +++ b/frontend/src/webparts/approvalAdmin/components/app/types.ts @@ -1 +1 @@ -export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests'; +export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser'; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserReplaceModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserReplaceModal.tsx new file mode 100644 index 0000000..4eddea7 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserReplaceModal.tsx @@ -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; + onClose: () => void; +} + +/** + * Выбор НОВОГО согласующего/исполнителя для замены (точечной или массовой) — + * урезанная версия ManageRequestsApproverModal: здесь нет SLA-полей (это не + * правка конкретного шага заявки, а массовая подмена человека), должность + * резолвится вызывающей стороной (см. ManageByUserScreen) там, где она вообще + * нужна — для живых заявок, не для шаблонов (там ExecutorEmail/Position + * взаимоисключающие, трогать Position нельзя). + */ +export const ManageByUserReplaceModal: React.FC = ({ + title, + employees, + employeesLoading, + onSave, + onClose +}) => { + const [email, setEmail] = React.useState(''); + const submit = useAsyncSubmit(); + + const handleSubmit = (): void => { + const trimmedEmail = email.trim(); + if (!trimmedEmail) { + submit.fail(texts.manageByUser.validationEmployee); + return; + } + submit.run(() => onSave(trimmedEmail)); + }; + + return ( + + + + + + + + ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss new file mode 100644 index 0000000..85f9d72 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.module.scss @@ -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; +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx new file mode 100644 index 0000000..11e9a3b --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx @@ -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 = { ok: true; value: T } | { ok: false; error: unknown }; + +// Promise.allSettled недоступен в целевом lib этого проекта (tsconfig.json — +// es5/es2015.*, без es2020.promise) — свой аналог вместо него: массовая замена +// не должна падать целиком из-за одной сломанной строки/шага (см. +// bulkReplaceLive/bulkReplaceTemplates ниже). +function settle(p: Promise): Promise> { + return p.then((value): Settled => ({ ok: true, value })).catch((error: unknown): Settled => ({ 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 = ({ 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(); + listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions)); + return map; + }, [listSeries.items]); + + const [liveRows, setLiveRows] = React.useState([]); + const [liveLoading, setLiveLoading] = React.useState(false); + const [liveError, setLiveError] = React.useState(); + const [checkedLiveKeys, setCheckedLiveKeys] = React.useState>(new Set()); + + const reloadLiveRows = React.useCallback(() => { + if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return; + setLiveLoading(true); + setLiveError(undefined); + requestManage + .findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES) + .then(setLiveRows) + .catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover'))) + .finally(() => setLiveLoading(false)); + }, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]); + + React.useEffect(() => { + setLiveRows([]); + setCheckedLiveKeys(new Set()); + reloadLiveRows(); + }, [reloadLiveRows]); + + const templateSteps = useTemplateRouteStepsByExecutor(api, employeeEmail || undefined); + const routeIds = React.useMemo(() => templateSteps.items.map(s => s.routeId), [templateSteps.items]); + const routesLookup = useTemplateRoutesLookup(api, routeIds); + const [checkedTemplateIds, setCheckedTemplateIds] = React.useState>(new Set()); + + const templateRows: UserTemplateStepRow[] = React.useMemo( + () => + templateSteps.items.map(step => { + const route = routesLookup.byId.get(step.routeId); + const requestType = route ? requestTypes.items.filter(rt => rt.id === route.requestTypeId)[0] : undefined; + const companyName = route?.companyId + ? dictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId) + : texts.routeSteps.anyCompany; + const departmentName = route?.departmentId + ? dictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId) + : texts.routeSteps.anyDepartment; + return { + ...step, + requestTypeName: requestType?.name ?? (route ? `#${route.requestTypeId}` : texts.common.empty), + companyName, + departmentName + }; + }), + [templateSteps.items, routesLookup.byId, requestTypes.items, dictionaries.companies, dictionaries.departments] + ); + + const toggleLive = (row: UserApprovalRow): void => + setCheckedLiveKeys(prev => { + const next = new Set(prev); + const key = liveRowKey(row); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const toggleTemplate = (row: UserTemplateStepRow): void => + setCheckedTemplateIds(prev => { + const next = new Set(prev); + if (next.has(row.id)) next.delete(row.id); + else next.add(row.id); + return next; + }); + + const checkedLiveRows = liveRows.filter(r => checkedLiveKeys.has(liveRowKey(r))); + const checkedTemplateRows = templateRows.filter(r => checkedTemplateIds.has(r.id)); + + const [replaceScope, setReplaceScope] = React.useState(); + 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 => { + 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[] = [ + { key: 'check', header: '', width: '32px', render: r => toggleLive(r)} /> }, + { key: 'type', header: texts.manageByUser.columnRequestType, render: r => r.requestTypeName }, + { + key: 'request', + header: texts.manageByUser.columnRequest, + render: r => {`${r.requestUniqueCode || `#${r.ticketId}`} ${r.requestTitle}`} + }, + { key: 'position', header: texts.manageByUser.columnPosition, render: r => r.position || texts.common.empty }, + { key: 'status', header: texts.manageByUser.columnStatus, render: r => {r.status} }, + { key: 'phase', header: texts.manageByUser.columnPhase, render: r => r.phase }, + { + key: 'actions', + header: '', + sticky: true, + render: r => ( + + setReplaceScope({ live: [r], templates: [] })} /> + + ) + } + ]; + + const templateColumns: IColumn[] = [ + { key: 'check', header: '', width: '32px', render: r => toggleTemplate(r)} /> }, + { + key: 'route', + header: texts.manageByUser.columnRoute, + render: r => {`${r.requestTypeName} — ${r.companyName} / ${r.departmentName}`} + }, + { key: 'stepStatus', header: texts.manageByUser.columnStepStatus, render: r => routeStepStatusLabels[r.status] }, + { key: 'condition', header: texts.manageByUser.columnCondition, render: r => describeStepCondition(r) ?? texts.common.empty }, + { + key: 'actions', + header: '', + sticky: true, + render: r => ( + + setReplaceScope({ live: [], templates: [r] })} /> + + ) + } + ]; + + const hasAnyRows = liveRows.length > 0 || templateRows.length > 0; + + return ( +
+ + setReplaceScope({ live: liveRows, templates: templateRows })} + > + {texts.manageByUser.replaceEverywhere} + + } + /> + + + + + + + + + + {employeeEmail && ( + +
+
{`${texts.manageByUser.liveTitle} (${liveRows.length})`}
+ +
+ {liveError && {`${texts.manageByUser.liveLoadError}: ${liveError}`}} + {liveLoading &&
{texts.common.loading}
} + {!liveLoading && !liveError && ( + + )} +
+ )} + + {employeeEmail && ( + +
+
{`${texts.manageByUser.templatesTitle} (${templateRows.length})`}
+ +
+ {templateSteps.error && {`${texts.manageByUser.templatesLoadError}: ${templateSteps.error}`}} + {templateSteps.isLoading &&
{texts.common.loading}
} + {!templateSteps.isLoading && !templateSteps.error && ( + r.id} emptyMessage={texts.manageByUser.templatesEmpty} /> + )} +
+ )} + + {replaceScope && ( + setReplaceScope(undefined)} + /> + )} + + {resultToast && ( + setResultToast(undefined)} /> + )} +
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/hooks/useTemplateRouteStepsByExecutor.ts b/frontend/src/webparts/approvalAdmin/hooks/useTemplateRouteStepsByExecutor.ts new file mode 100644 index 0000000..25d6c3c --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useTemplateRouteStepsByExecutor.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { TemplateRouteStep } from '../models'; +import { ApprovalRouteApi } from '../services/api'; +import { AsyncList, useAsyncList } from './useAsyncList'; + +/** Шаги ВСЕХ маршрутов с данным ExecutorEmail — для экрана «Заявки по согласующему». */ +export function useTemplateRouteStepsByExecutor( + api: ApprovalRouteApi, + executorEmail: string | undefined +): AsyncList { + const load = React.useCallback( + () => (executorEmail ? api.templateRouteSteps.listByExecutorEmail(executorEmail) : Promise.resolve([])), + [api, executorEmail] + ); + return useAsyncList(load); +} diff --git a/frontend/src/webparts/approvalAdmin/hooks/useTemplateRoutesLookup.ts b/frontend/src/webparts/approvalAdmin/hooks/useTemplateRoutesLookup.ts new file mode 100644 index 0000000..261f9b6 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useTemplateRoutesLookup.ts @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { TemplateRoute } from '../models'; +import { ApprovalRouteApi } from '../services/api'; + +export interface TemplateRoutesLookup { + byId: Map; + isLoading: boolean; + error?: string; +} + +/** + * Дочитывает TemplateRoute по списку id (например, найденных через + * useTemplateRouteStepsByExecutor) — bulk-эндпоинта нет, поэтому по одному + * запросу на маршрут параллельно. Нужен для отображения (тип заявки/компания/ + * отдел маршрута), сам TemplateRouteStepDto хранит только routeId. + */ +export function useTemplateRoutesLookup(api: ApprovalRouteApi, routeIds: number[]): TemplateRoutesLookup { + const [byId, setById] = React.useState>(new Map()); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(); + + // Стабильный ключ вместо самого массива routeIds — иначе (новая ссылка на + // массив с тем же содержимым при каждом рендере вызывающего экрана) эффект + // перезапускался бы бесконечно. + const key = React.useMemo(() => Array.from(new Set(routeIds)).sort((a, b) => a - b).join(','), [routeIds]); + + React.useEffect(() => { + const ids = key ? key.split(',').map(Number) : []; + if (ids.length === 0) { + setById(new Map()); + return; + } + + let alive = true; + setIsLoading(true); + setError(undefined); + Promise.all(ids.map(id => api.templateRoutes.get(id))) + .then(routes => { + if (!alive) return; + setById(new Map(routes.map(r => [r.id, r]))); + }) + .catch((e: Error) => { + if (alive) setError(e.message); + }) + .finally(() => { + if (alive) setIsLoading(false); + }); + return () => { + alive = false; + }; + }, [api, key]); + + return { byId, isLoading, error }; +} diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index c800bf9..2a19b8d 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -7,3 +7,4 @@ export * from './templateRoutes'; export * from './templateRouteSteps'; export * from './resolve'; export * from './requestManage'; +export * from './manageByUser'; diff --git a/frontend/src/webparts/approvalAdmin/models/manageByUser.ts b/frontend/src/webparts/approvalAdmin/models/manageByUser.ts new file mode 100644 index 0000000..6bb6f31 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/models/manageByUser.ts @@ -0,0 +1,31 @@ +// Домен вкладки «Заявки по согласующему» (ManageByUserScreen) — сводит вместе +// два независимых источника по одному конкретному человеку: живые строки +// согласования (SharePoint, requestManageService.ts) и шаблонные шаги +// маршрутов (Tnsu.ApprovalRoute.Api, TemplateRouteStep с ExecutorEmail). +import { ManagedApprovalRow } from './requestManage'; +import { TemplateRouteStep } from './templateRouteSteps'; + +/** + * Строка согласования, найденная сразу по ВСЕМ типам заявок и ВСЕМ их + * партициям — в отличие от ManagedApprovalRow (одна открытая заявка одного + * типа), здесь нужен контекст, к какому типу/списку/партиции она относится, + * чтобы её можно было и показать, и записать обратно через reassignApprover. + */ +export interface UserApprovalRow extends ManagedApprovalRow { + requestTypeId: number; + requestTypeName: string; + siteUrl: string; + /** Партиция списка ДАННЫХ, где физически лежит заявка. */ + listId: string; + approvalListTitle: string; + requestTitle: string; + // eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть + requestUniqueCode: string | null; +} + +/** Шаг шаблона маршрута с ExecutorEmail = искомый человек, обогащённый для отображения. */ +export interface UserTemplateStepRow extends TemplateRouteStep { + requestTypeName: string; + companyName: string; + departmentName: string; +} diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index 65b6d31..d8ac489 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -67,7 +67,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change'; // 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а // состояние вкладки 'routes' при наличии aaroute (см. parseRoute). -const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests']; +const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests', 'manageByUser']; const PARAM_KEYS = [ 'aatab', 'aaroute', 'aarfilter', diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index 4b8fe10..ad837d2 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -99,6 +99,12 @@ export class ApprovalRouteApi { this.http.patch(`/api/template-routes/${routeId}/steps/${id}`, body), remove: (routeId: number, id: number): Promise => - this.http.delete(`/api/template-routes/${routeId}/steps/${id}`) + this.http.delete(`/api/template-routes/${routeId}/steps/${id}`), + + // Сквозной поиск ПО ВСЕМ маршрутам разом (не вложен под конкретный routeId) — + // для экрана «Заявки по согласующему» (ManageByUserScreen): шаги, где + // исполнитель задан этим конкретным email, а не должностью/ролью. + listByExecutorEmail: (executorEmail: string): Promise => + this.http.get('/api/template-route-steps', { executorEmail }) }; } diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts index 3e94173..890e908 100644 --- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts +++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts @@ -4,6 +4,10 @@ import '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import '@pnp/sp/search'; +// sp.batched() — findActiveApprovalRowsForApprover читает все партиции ОДНОГО +// сайта одним HTTP-запросом (см. метод ниже). Импорт side-effect'ный: без него +// метод есть в типах, но в рантайме не зарегистрирован. +import '@pnp/sp/batching'; import { AadHttpClient } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { @@ -12,7 +16,9 @@ import { ManagedRequestMatrix, ManagedRequestPage, ManagedRequestSummary, - RequestListPartition + RequestListPartition, + RequestType, + UserApprovalRow } from '../models'; // Список данных «<Форма> — данные» → список согласования «<Форма> — согласование» @@ -280,32 +286,39 @@ export class RequestManageService { .expand(FIELDS.approver) .orderBy(FIELDS.order, true)()) as Array>; - const rows: ManagedApprovalRow[] = rawRows.map((r, i) => { - const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined; - return { - id: r.Id as number, - ticketId: requestId, - approverId: approver?.Id ?? null, - approverName: approver?.Title || '', - approverEmail: approver?.EMail || '', - position: (r.Title as string) || '', - status: (r[FIELDS.status] as string) || '', - date: formatDateIso((r[FIELDS.date] as string) || ''), - dateIso: (r[FIELDS.date] as string) || '', - comment: (r[FIELDS.comment] as string) || '', - order: (r[FIELDS.order] as number) ?? i + 1, - allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false, - phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval, - // ?? null, а не || null: ноль здесь — значимое значение («напоминать - // сразу»), и превращать его в «не задано» нельзя. - slaHours: (r[FIELDS.slaHours] as number | null) ?? null, - askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null - }; - }); + const rows: ManagedApprovalRow[] = rawRows.map((r, i) => this.toApprovalRow(r, requestId, i)); return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows }; } + /** + * Строка списка согласования (сырые OData-поля) → ManagedApprovalRow. Общий + * маппинг для getMatrix (одна заявка, ticketId известен заранее) и + * findActiveApprovalRowsForApprover (много заявок сразу, ticketId — из самой строки). + */ + private toApprovalRow(r: Record, ticketId: number, index: number): ManagedApprovalRow { + const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined; + return { + id: r.Id as number, + ticketId, + approverId: approver?.Id ?? null, + approverName: approver?.Title || '', + approverEmail: approver?.EMail || '', + position: (r.Title as string) || '', + status: (r[FIELDS.status] as string) || '', + date: formatDateIso((r[FIELDS.date] as string) || ''), + dateIso: (r[FIELDS.date] as string) || '', + comment: (r[FIELDS.comment] as string) || '', + order: (r[FIELDS.order] as number) ?? index + 1, + allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false, + phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval, + // ?? null, а не || null: ноль здесь — значимое значение («напоминать + // сразу»), и превращать его в «не задано» нельзя. + slaHours: (r[FIELDS.slaHours] as number | null) ?? null, + askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null + }; + } + /** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */ private async resolveUserId(sp: SPFI, email: string): Promise { const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`; @@ -313,6 +326,166 @@ export class RequestManageService { return user.Id; } + /** + * Активные строки согласования данного человека СРАЗУ по всем типам заявок и + * всем их партициям (архив + текущая) — для экрана «Заявки по согласующему» + * (ManageByUserScreen): найти всё, что нужно переназначить перед увольнением/ + * сменой должности, без похода в каждый тип заявки по отдельности. + * + * REST-only, без Search API — в отличие от request-portal/myRequests.service.ts + * (тот ищет по текущему пользователю на КАЖДОЙ загрузке страницы, и там счёт + * идёт на миллисекунды). Здесь редкий admin-вызов на одного конкретного + * сотрудника, а задержка индексации Search — это ровно тот класс багов + * (устаревшее назначение), ради исправления которого существует сам экран, так + * что жертвовать точностью ради скорости здесь не место. + * + * partitionsByType — серия партиций КАЖДОГО типа (архив+текущая), уже + * полученная вызывающей стороной через api.requestTypes.listsAll(). Partition + * даёт Title списка данных бесплатно (без похода в REST) — approvalListTitle + * выводится из него той же конвенцией имён, что и listTitles() выше. + */ + public async findActiveApprovalRowsForApprover( + requestTypes: RequestType[], + partitionsByType: Map, + approverEmail: string, + activeStatuses: string[] + ): Promise { + interface ITarget { + requestTypeId: number; + requestTypeName: string; + siteUrl: string; + dataListId: string; + approvalListTitle: string; + } + + const targets: ITarget[] = []; + requestTypes.forEach(rt => { + (partitionsByType.get(rt.id) || []).forEach(p => { + if (!p.title || !DATA_LIST_SUFFIX.test(p.title)) return; + targets.push({ + requestTypeId: rt.id, + requestTypeName: rt.name, + siteUrl: p.siteUrl, + dataListId: p.listId, + approvalListTitle: `${p.title.replace(DATA_LIST_SUFFIX, '')} — согласование` + }); + }); + }); + if (targets.length === 0) return []; + + const bySite = new Map(); + targets.forEach(t => bySite.set(t.siteUrl, [...(bySite.get(t.siteUrl) || []), t])); + + const statusFilter = activeStatuses.map(s => `${FIELDS.status} eq '${s}'`).join(' or '); + const rows: UserApprovalRow[] = []; + + await Promise.all( + Array.from(bySite.values()).map(async siteTargets => { + const siteUrl = siteTargets[0].siteUrl; + try { + const sp = this.clientFor(siteUrl); + // Один раз на сайт — тот же приём, что в fetchRecentApprovalRows + // request-portal: резолвить email в Id и фильтровать по approverId + // надёжнее, чем `TansuSiteApprover/EMail eq '...'` в $filter по + // Person-полю (последнее в SharePoint REST ненадёжно). + const approverId = await this.resolveUserId(sp, approverEmail); + + // Фаза A: строки согласования — ОДИН batch-запрос на все партиции сайта. + const approvalHitsByList = new Map>>(); + const [batchedApprovalSp, executeApproval] = sp.batched(); + const pendingApproval = siteTargets.map(t => + batchedApprovalSp.web.lists + .getByTitle(t.approvalListTitle) + .items.filter(`${FIELDS.approver}Id eq ${approverId} and (${statusFilter})`) + .select( + 'Id', + 'Title', + FIELDS.status, + FIELDS.date, + FIELDS.comment, + FIELDS.order, + FIELDS.allowReassign, + FIELDS.phase, + FIELDS.slaHours, + FIELDS.askOverdueReason, + `${FIELDS.ticket}Id`, + `${FIELDS.approver}/Id`, + `${FIELDS.approver}/Title`, + `${FIELDS.approver}/EMail` + ) + .expand(FIELDS.approver) + .top(2000)() + .then(items => { approvalHitsByList.set(t.dataListId, items as Array>); }) + .catch((e: Error) => { + // Одна сломанная/переименованная вручную партиция не должна + // лишить результата остальные партиции того же сайта. + console.error(`[Заявки по согласующему] «${t.approvalListTitle}» (${t.dataListId}): ${e.message}`); + }) + ); + await executeApproval(); + await Promise.all(pendingApproval); + + // Фаза B: Title/статус/уникальный код заявок — по найденным Id, + // сгруппированным по СПИСКУ ДАННЫХ, тоже одним batch-запросом на сайт. + const ticketIdsByList = new Map>(); + siteTargets.forEach(t => { + (approvalHitsByList.get(t.dataListId) || []).forEach(h => { + const ticketId = h[`${FIELDS.ticket}Id`] as number | undefined; + if (!ticketId) return; + const set = ticketIdsByList.get(t.dataListId) || new Set(); + set.add(ticketId); + ticketIdsByList.set(t.dataListId, set); + }); + }); + + const dataRowsByList = new Map(); + if (ticketIdsByList.size > 0) { + const [batchedDataSp, executeData] = sp.batched(); + const pendingData = Array.from(ticketIdsByList.entries()).map(([dataListId, idsSet]) => { + const ids = Array.from(idsSet); + return batchedDataSp.web + .getList(dataListId) + .items.filter(ids.map(id => `Id eq ${id}`).join(' or ')) + .select('Id', 'Title', 'RequestStatus', 'Created', 'RequestUniqueCode') + .top(ids.length)() + .then(items => { dataRowsByList.set(dataListId, items as IRawDataRow[]); }) + .catch((e: Error) => { + console.error(`[Заявки по согласующему] список данных ${dataListId}: ${e.message}`); + }); + }); + await executeData(); + await Promise.all(pendingData); + } + + // Склейка. + siteTargets.forEach(t => { + const hits = approvalHitsByList.get(t.dataListId) || []; + const dataById = new Map((dataRowsByList.get(t.dataListId) || []).map(r => [r.Id, r])); + hits.forEach((h, i) => { + const ticketId = h[`${FIELDS.ticket}Id`] as number; + if (!ticketId) return; + const dataRow = dataById.get(ticketId); + rows.push({ + ...this.toApprovalRow(h, ticketId, i), + requestTypeId: t.requestTypeId, + requestTypeName: t.requestTypeName, + siteUrl: t.siteUrl, + listId: t.dataListId, + approvalListTitle: t.approvalListTitle, + requestTitle: dataRow?.Title || `#${ticketId}`, + requestUniqueCode: dataRow?.RequestUniqueCode || null + }); + }); + }); + } catch (e) { + console.error(`[Заявки по согласующему] сайт ${siteUrl}: ${(e as Error).message}`); + } + }) + ); + + return rows; + } + /** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана * проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */ public async reassignApprover( diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index aaafee0..3823a10 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -77,7 +77,8 @@ export const texts = { requestTypes: 'Типы заявок', routes: 'Маршруты', resolve: 'Проверка маршрута', - manageRequests: 'Редактирование заявок' + manageRequests: 'Редактирование заявок', + manageByUser: 'Заявки по согласующему' }, app: { @@ -440,6 +441,42 @@ export const texts = { saveError: 'Не удалось сохранить' }, + manageByUser: { + breadcrumb: 'Заявки по согласующему', + subtitle: 'Массовая замена согласующего/исполнителя', + title: 'Заявки по согласующему', + employeeLabel: 'Сотрудник', + employeePlaceholder: 'Выберите сотрудника', + employeeHint: 'Покажет все его активные заявки (по всем типам и партициям) и все шаблонные шаги, где он назначен лично.', + replaceEverywhere: 'Заменить везде', + liveTitle: 'Активные заявки', + liveEmpty: 'Активных заявок нет.', + liveLoadError: 'Не удалось загрузить заявки', + columnRequestType: 'Тип заявки', + columnRequest: 'Заявка', + columnPosition: 'Должность', + columnStatus: 'Статус', + columnPhase: 'Фаза', + templatesTitle: 'Шаблоны маршрутов', + templatesEmpty: 'Шагов с этим исполнителем нет.', + templatesLoadError: 'Не удалось загрузить шаги маршрутов', + columnRoute: 'Маршрут', + columnStepStatus: 'Статус шага', + columnCondition: 'Условие', + replaceOne: 'Заменить', + 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: { placeholder: 'Выберите значение',