diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs index f2992f6..5fb61b6 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs @@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize(Policy = AuthPolicies.Any)] [Route("api/reports")] -public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase +public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase { private const string XlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; @@ -54,19 +54,19 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll /// /// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип). - /// POST, потому что строки собирает админка — у неё уже есть справочники - /// сотрудников и подписи шагов (см. RouteExportWorkbook). + /// POST — потому что в теле список id типов (см. RouteExportService). /// [HttpPost("routes")] [Produces(XlsxContentType)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public IActionResult Routes([FromBody] RouteExportRequest request) + public async Task Routes([FromBody] RouteExportRequest request, CancellationToken ct = default) { - if (request.Sheets is not { Count: > 0 }) - return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } }); + var result = await routeExport.BuildAsync(request.RequestTypeIds, ct); + if (result.IsFailed) + return BadRequest(new { errors = result.Errors.Select(e => e.Message) }); - var file = RouteExportWorkbook.Build(request); + var file = result.Value; var disposition = new ContentDispositionHeaderValue("attachment"); disposition.SetHttpFileName(RouteExportWorkbook.FileName); diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 4ae58c7..64527fc 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static class ApplicationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs new file mode 100644 index 0000000..22d4569 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs @@ -0,0 +1,249 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.Reports; + +/// +/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов +/// заявок, превращает их в понятные человеку строки (роль, исполнитель, +/// организация, условие, комментарий) и отдаёт на вёрстку в +/// . +/// +/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага, +/// ), у каждой своя нумерация. +/// +public sealed class RouteExportService( + ApprovalRouteDbContext db, + ITnsuPortalClient portalClient, + IOptions portalOptions) +{ + private const string Empty = "—"; + private const string Always = "Всегда"; + private const string AnyOrganization = "Любая"; + private const string AnyCompany = "любая компания"; + private const string AnyDepartment = "любой отдел"; + private const string ByRequest = "Определяется по заявке"; + + /// + /// Технические имена колонок списка, которые в таблице показываем по-человечески + /// (CompaniesJson — организации заявки). + /// + private static readonly Dictionary ConditionFieldAliases = new(StringComparer.Ordinal) + { + ["CompaniesJson"] = "организация" + }; + + private static readonly Dictionary ProjectRoleLabels = new() + { + [ProjectRole.ProjectManager] = "Руководитель проекта", + [ProjectRole.Planner] = "Планировщик", + [ProjectRole.ManagerHSE] = "Менеджер HSE", + [ProjectRole.Security] = "Безопасность", + [ProjectRole.Keeper] = "Заведующий склада" + }; + + private static readonly Dictionary HeadTypeLabels = new() + { + [HeadType.Chief] = "Непосредственный руководитель", + [HeadType.DirectionHead] = "Руководитель направления", + [HeadType.DirectorTansu] = "Директор Tansu", + [HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu", + [HeadType.DepartmentHead] = "Руководитель указанного отдела", + [HeadType.CompanyDirectors] = "Директора компаний инициатора" + }; + + private static readonly Dictionary OperatorSymbols = new() + { + [ComparisonOperator.GreaterThan] = ">", + [ComparisonOperator.LessThan] = "<", + [ComparisonOperator.Equal] = "=", + [ComparisonOperator.NotEqual] = "≠", + [ComparisonOperator.Contains] = "содержит", + [ComparisonOperator.NotContains] = "не содержит" + }; + + public async Task> BuildAsync(IReadOnlyList? requestTypeIds, CancellationToken cancellationToken) + { + if (requestTypeIds is not { Count: > 0 }) + return Result.Fail("Не выбрано ни одного типа заявки."); + + var types = await db.RequestTypes.AsNoTracking() + .Where(x => requestTypeIds.Contains(x.Id)) + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + if (types.Count == 0) + return Result.Fail("Выбранные типы заявок не найдены."); + + var routes = await db.TemplateRoutes.AsNoTracking() + .Where(x => requestTypeIds.Contains(x.RequestTypeId)) + .Include(x => x.Steps).ThenInclude(s => s.Conditions) + .OrderBy(x => x.Id) + .ToListAsync(cancellationToken); + + var directory = await LoadDirectoryAsync(cancellationToken); + + var sheets = types + .Select(type => BuildSheet(type, routes.Where(r => r.RequestTypeId == type.Id).ToList(), directory)) + .ToList(); + + return Result.Ok(RouteExportWorkbook.Build(sheets)); + } + + private async Task LoadDirectoryAsync(CancellationToken cancellationToken) + { + // Справочники кешируются клиентом портала (см. CachingTnsuPortalClient). + var employees = await portalClient.GetAllEmployeesAsync(cancellationToken); + var companies = await portalClient.GetCompaniesAsync(cancellationToken); + var departments = await portalClient.GetDepartmentsAsync(cancellationToken); + + return new PortalDirectory( + employees, + companies.GroupBy(c => c.Id).ToDictionary(g => g.Key, g => g.First().Name), + departments.GroupBy(d => d.Id).ToDictionary(g => g.Key, g => g.First().Name)); + } + + private RouteExportSheet BuildSheet(RequestType type, IReadOnlyList routes, PortalDirectory directory) + { + var sections = routes + .Select(route => + { + var organization = route.CompanyId is { } companyId + ? directory.CompanyName(companyId) + : AnyOrganization; + return new RouteExportSection( + routes.Count < 2 ? null : RouteTitle(route, directory), + BuildGroups(route.Steps, organization, directory)); + }) + .ToList(); + + return new RouteExportSheet(type.Name, sections); + } + + private static string RouteTitle(TemplateRoute route, PortalDirectory directory) + { + var company = route.CompanyId is { } c ? directory.CompanyName(c) : AnyCompany; + var department = route.DepartmentId is { } d ? directory.DepartmentName(d) : AnyDepartment; + return $"Маршрут: {company} · {department}{(route.ByProject ? " · по проекту" : string.Empty)}"; + } + + private List BuildGroups(IEnumerable steps, string organization, PortalDirectory directory) + { + var ordered = steps.OrderBy(s => s.OrderNo).ToList(); + var approvers = ordered.Where(s => s.Status != RouteStepStatus.Execution).ToList(); + var executors = ordered.Where(s => s.Status == RouteStepStatus.Execution).ToList(); + + return new[] + { + new RouteExportGroup("Согласующие", BuildRows(approvers, organization, directory)), + new RouteExportGroup("Исполнители", BuildRows(executors, organization, directory)) + } + .Where(g => g.Rows.Count > 0) + .ToList(); + } + + // Параллельные шаги (равный OrderNo) получают один и тот же номер. + private List BuildRows(IReadOnlyList steps, string organization, PortalDirectory directory) + { + var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList(); + + return steps + .Select(step => + { + var (role, executor) = DescribeRoleAndExecutor(step, directory); + return new RouteExportRow( + orderNos.IndexOf(step.OrderNo) + 1, + role, + executor, + organization, + DescribeCondition(step), + DescribeComment(step)); + }) + .ToList(); + } + + // «Роль» — должность или тип руководителя, «исполнитель» — конкретный человек, + // если он известен уже при настройке маршрута. Директор Tansu и Управляющий + // директор Tansu — тоже конкретные люди (их email лежит в настройках). + private (string Role, string Executor) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory) + { + if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) + { + var employee = directory.FindByEmail(step.ExecutorEmail); + return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail); + } + + if (!string.IsNullOrWhiteSpace(step.Position)) + return (step.Position, directory.FindByPosition(step.Position)?.Fio ?? Empty); + + if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None) + return (ProjectRoleLabels[projectRole], ByRequest); + + if (step.HeadType is { } headType && headType != HeadType.None) + { + var fixedEmail = headType switch + { + HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail, + HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail, + _ => null + }; + if (fixedEmail is not null) + // Нет в справочнике — лучше показать email, чем пустую ячейку. + return (HeadTypeLabels[headType], directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail); + + return (HeadTypeLabels[headType], ByRequest); + } + + return (Empty, Empty); + } + + private static string DescribeCondition(TemplateRouteStep step) + { + if (step.Conditions.Count == 0) + return Always; + + var joiner = step.ConditionsLogic == ConditionsLogic.Or ? " ИЛИ " : " И "; + return string.Join(joiner, step.Conditions.OrderBy(c => c.OrderNo).Select(c => + { + var field = ConditionFieldAliases.GetValueOrDefault(c.FieldName, c.FieldName); + var symbol = OperatorSymbols.GetValueOrDefault(c.Operation, string.Empty); + return $"{field} {symbol} {c.Value}"; + })); + } + + // Галочка «можно переназначить» — шаг может передать другому: согласующий + // назначает согласующего, исполнитель — исполнителя. + private static string DescribeComment(TemplateRouteStep step) => + !step.AllowReassignToOtherApprovers + ? string.Empty + : step.Status == RouteStepStatus.Execution ? "назначает исполнителя" : "назначает согласующего"; + + private static string EmptyIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? Empty : value; + + private sealed class PortalDirectory( + IReadOnlyList employees, + IReadOnlyDictionary companies, + IReadOnlyDictionary departments) + { + // Справочник даёт строку на каждую компанию сотрудника — берём первую с + // заполненной должностью, остальные для ФИО/должности ничего не добавляют. + public EmployeeContactInfo? FindByEmail(string email) => + employees.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(e => !string.IsNullOrWhiteSpace(e.Position)) + .FirstOrDefault(); + + public EmployeeContactInfo? FindByPosition(string position) => + employees.FirstOrDefault(e => string.Equals(e.Position?.Trim(), position.Trim(), StringComparison.OrdinalIgnoreCase)); + + // Пока справочник не отдал имя (или запись удалена) — усечённый Guid, как в админке. + public string CompanyName(Guid id) => companies.GetValueOrDefault(id) ?? ShortGuid(id); + + public string DepartmentName(Guid id) => departments.GetValueOrDefault(id) ?? ShortGuid(id); + + private static string ShortGuid(Guid id) => id.ToString()[..8]; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs index c7cfc4b..eb1fddd 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs @@ -4,28 +4,37 @@ using NPOI.XSSF.UserModel; namespace Tnsu.ApprovalRoute.Application.Reports; -/// Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка). -public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition); +/// Строка выгрузки маршрута: шаг в понятном человеку виде. +public sealed record RouteExportRow( + int Number, + string Role, + string Executor, + string Organization, + string Condition, + string Comment); + +/// Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация). +public sealed record RouteExportGroup(string Caption, IReadOnlyList Rows); /// /// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов /// (по компании/отделу) и их нужно различать на одном листе; у единственного /// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». /// -public sealed record RouteExportSection(string? Title, IReadOnlyList Rows); +public sealed record RouteExportSection(string? Title, IReadOnlyList Groups); /// Лист выгрузки — один тип заявки. public sealed record RouteExportSheet(string Name, IReadOnlyList Sections); -public sealed record RouteExportRequest(IReadOnlyList Sheets); +/// Тело запроса POST api/reports/routes — какие типы заявок выгрузить. +public sealed record RouteExportRequest(IReadOnlyList RequestTypeIds); /// /// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: /// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». /// -/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки: -/// там уже есть справочники сотрудников и логика описания шага, дублировать их -/// на бэкенде незачем. Здесь только вёрстка файла. +/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает +/// . /// public static class RouteExportWorkbook { @@ -37,12 +46,14 @@ public static class RouteExportWorkbook (" ", 6.0), ("Роль ", 41.1), ("Исполнитель ", 42.2), + ("Организация ", 28.0), ("Условие ", 66.2), + ("Комментарий ", 36.0), ]; public const string FileName = "Маршруты согласования.xlsx"; - public static byte[] Build(RouteExportRequest request) + public static byte[] Build(IReadOnlyList sheets) { var workbook = new XSSFWorkbook(); @@ -51,7 +62,7 @@ public static class RouteExportWorkbook var titleStyle = CreateStyle(workbook, bold: true, border: false); var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var sheetData in request.Sheets) + foreach (var sheetData in sheets) { var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames)); for (var i = 0; i < Columns.Length; i++) @@ -60,8 +71,6 @@ public static class RouteExportWorkbook var rowIndex = 0; foreach (var section in sheetData.Sections) { - if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами - if (!string.IsNullOrWhiteSpace(section.Title)) { var titleRow = sheet.CreateRow(rowIndex); @@ -72,23 +81,37 @@ public static class RouteExportWorkbook rowIndex++; } - var header = sheet.CreateRow(rowIndex++); - for (var i = 0; i < Columns.Length; i++) + foreach (var group in section.Groups) { - var cell = header.CreateCell(i); - cell.SetCellValue(Columns[i].Title); - cell.CellStyle = headerStyle; - } + var captionRow = sheet.CreateRow(rowIndex); + var captionCell = captionRow.CreateCell(0); + captionCell.SetCellValue(group.Caption); + captionCell.CellStyle = titleStyle; + sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1)); + rowIndex++; - foreach (var row in section.Rows) - { - var excelRow = sheet.CreateRow(rowIndex++); - var numberCell = excelRow.CreateCell(0); - numberCell.SetCellValue(row.Number); - numberCell.CellStyle = cellStyle; - WriteText(excelRow, 1, row.Role, cellStyle); - WriteText(excelRow, 2, row.Executor, cellStyle); - WriteText(excelRow, 3, row.Condition, cellStyle); + var header = sheet.CreateRow(rowIndex++); + for (var i = 0; i < Columns.Length; i++) + { + var cell = header.CreateCell(i); + cell.SetCellValue(Columns[i].Title); + cell.CellStyle = headerStyle; + } + + foreach (var row in group.Rows) + { + var excelRow = sheet.CreateRow(rowIndex++); + var numberCell = excelRow.CreateCell(0); + numberCell.SetCellValue(row.Number); + numberCell.CellStyle = cellStyle; + WriteText(excelRow, 1, row.Role, cellStyle); + WriteText(excelRow, 2, row.Executor, cellStyle); + WriteText(excelRow, 3, row.Organization, cellStyle); + WriteText(excelRow, 4, row.Condition, cellStyle); + WriteText(excelRow, 5, row.Comment, cellStyle); + } + + rowIndex++; // пустая строка между таблицами } } } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs index 4b931e2..4947094 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC public Task> GetAllEmployeesAsync(CancellationToken cancellationToken) => GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken)); + public Task> GetCompaniesAsync(CancellationToken cancellationToken) => + GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken)); + + public Task> GetDepartmentsAsync(CancellationToken cancellationToken) => + GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken)); + public Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => GetOrAddAsync( $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 4a56e4e..8fb9a9c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient( return employees .Where(e => !string.IsNullOrWhiteSpace(e.Fio)) - .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty)) + .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty, e.Position)) + .ToList(); + } + + // /Dictionary/* отдаёт «голый» массив с ключами в PascalCase (Id/Name) — + // PropertyNameCaseInsensitive в JsonOptions это покрывает. + public async Task> GetCompaniesAsync(CancellationToken cancellationToken) + { + var companies = await GetAsync>("/Dictionary/Companies", cancellationToken); + return (companies ?? []) + .Where(c => c.Id is not null && !string.IsNullOrWhiteSpace(c.Name)) + .Select(c => new CompanyInfo(c.Id!.Value, c.Name!)) + .ToList(); + } + + public async Task> GetDepartmentsAsync(CancellationToken cancellationToken) + { + var departments = await GetAsync>("/Dictionary/Departments", cancellationToken); + return (departments ?? []) + .Where(d => d.Id is not null && !string.IsNullOrWhiteSpace(d.Name)) + .Select(d => new DepartmentInfo(d.Id!.Value, d.Name!)) .ToList(); } @@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient( [property: JsonPropertyName("fio")] string? Fio, [property: JsonPropertyName("id")] Guid? Id = null); + private sealed record DictionaryItemResponse( + [property: JsonPropertyName("id")] Guid? Id, + [property: JsonPropertyName("name")] string? Name); + private sealed record ManagerResponse( [property: JsonPropertyName("managerEmail")] string? ManagerEmail); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 4da2118..0222c44 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -20,6 +20,12 @@ public interface ITnsuPortalClient /// Task> GetAllEmployeesAsync(CancellationToken cancellationToken); + /// Справочник компаний — /Dictionary/Companies. + Task> GetCompaniesAsync(CancellationToken cancellationToken); + + /// Справочник отделов — /Dictionary/Departments. + Task> GetDepartmentsAsync(CancellationToken cancellationToken); + Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); /// diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index 7940891..ee37eb2 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company, /// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно /// по — он один и тот же в обоих ответах. /// -public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company); +public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company, string? Position = null); + +/// Компания из справочника /Dictionary/Companies. +public sealed record CompanyInfo(Guid Id, string Name); + +/// Отдел из справочника /Dictionary/Departments. +public sealed record DepartmentInfo(Guid Id, string Name); diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index b568590..c2bab51 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -186,7 +186,7 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint route={route.resolve} /> )} - {route.tab === 'routesExport' && } + {route.tab === 'routesExport' && } {route.tab === 'manageRequests' && ( )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx index 5407a06..4ed796a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx @@ -1,19 +1,13 @@ import * as React from 'react'; import { ApprovalRouteApi } from '../../services/api'; -import { PortalDictionaryClient } from '../../services/portalDictionary'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; -import { usePortalEmployees } from '../../hooks/usePortalEmployees'; -import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { reportError } from '../../utils/errors'; -import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport'; import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui'; export interface IRoutesExportScreenProps { api: ApprovalRouteApi; - /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ - dictionary?: PortalDictionaryClient; } // Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно @@ -31,10 +25,8 @@ function download(blob: Blob, fileName: string): void { URL.revokeObjectURL(url); } -export const RoutesExportScreen: React.FC = ({ api, dictionary }) => { +export const RoutesExportScreen: React.FC = ({ api }) => { const requestTypes = useRequestTypes(api); - const employees = usePortalEmployees(dictionary); - const portalDictionaries = usePortalDictionaries(dictionary); const [storedSelected, setSelected] = useLocalStorageState(SELECTED_STORAGE_KEY, []); const [isBusy, setIsBusy] = React.useState(false); @@ -51,27 +43,12 @@ export const RoutesExportScreen: React.FC = ({ api, di const runExport = (): void => { setError(undefined); setIsBusy(true); - // Порядок листов — как в списке типов, а не в порядке кликов. - const types = requestTypes.items.filter(rt => selected.indexOf(rt.id) !== -1); - - Promise.all( - types.map(async rt => { - const routes = await api.templateRoutes.list(rt.id); - const items: RouteWithSteps[] = await Promise.all( - routes.map(async route => ({ route, steps: await api.templateRouteSteps.list(route.id) })) - ); - return buildRouteSheet(rt, items, employees.items, portalDictionaries.companies, portalDictionaries.departments); - }) - ) - .then(sheets => api.reports.routes({ sheets })) + api.reports.routes(selected) .then(blob => download(blob, texts.routesExport.fileName)) .catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export')))) .then(() => setIsBusy(false), () => setIsBusy(false)); }; - // Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться. - const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading; - return (
@@ -83,7 +60,7 @@ export const RoutesExportScreen: React.FC = ({ api, di