From 0e6a7064d278a6b6abd1f62ba27b07fb1fba9a7e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 14 Oct 2026 05:06:58 +0000 Subject: [PATCH] Feature/routes export (#29) Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/29 --- .../Controllers/ReportsController.cs | 25 +- backend/Tnsu.ApprovalRoute.Api/Program.cs | 5 +- .../Tnsu.ApprovalRoute.Api.csproj | 3 + .../ApplicationServiceCollectionExtensions.cs | 1 + .../Reports/RouteExportService.cs | 312 ++++++++++++++++++ .../Reports/RouteExportWorkbook.cs | 172 ++++++++++ .../TnsuPortal/CachingTnsuPortalClient.cs | 6 + .../TnsuPortal/HttpTnsuPortalClient.cs | 26 +- .../TnsuPortal/ITnsuPortalClient.cs | 6 + .../TnsuPortal/TnsuPortalDtos.cs | 8 +- .../approvalAdmin/components/app/AdminApp.tsx | 7 +- .../approvalAdmin/components/app/types.ts | 1 + .../RequestTypeAccessScreen.module.scss | 21 ++ .../screens/RequestTypeAccessScreen.tsx | 88 ++++- .../components/screens/RoutesExportScreen.tsx | 99 ++++++ .../ui/SearchableSelect.module.scss | 1 + .../components/ui/SearchableSelect.tsx | 3 + .../src/webparts/approvalAdmin/routing.ts | 2 +- .../webparts/approvalAdmin/services/api.ts | 8 +- .../approvalAdmin/services/httpClient.ts | 39 +++ frontend/src/webparts/approvalAdmin/texts.ts | 27 +- 21 files changed, 832 insertions(+), 28 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs index f25a271..8a565f5 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"; @@ -51,4 +51,27 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll return File(file, XlsxContentType); } + + /// + /// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип). + /// POST — потому что в теле список id типов (см. RouteExportService). + /// + [HttpPost("routes")] + [Produces(XlsxContentType)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Routes([FromBody] RouteExportRequest request, CancellationToken ct = default) + { + var result = await routeExport.BuildAsync(request.RequestTypeIds, ct); + if (result.IsFailed) + return BadRequest(new { errors = result.Errors.Select(e => e.Message) }); + + var file = result.Value; + + var disposition = new ContentDispositionHeaderValue("attachment"); + disposition.SetHttpFileName(file.FileName); + Response.Headers.ContentDisposition = disposition.ToString(); + + return File(file.Content, XlsxContentType); + } } diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 2f2a8f8..ec6d3bd 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -53,7 +53,10 @@ builder.Services.AddCors(options => options.AddPolicy(CorsPolicyName, policy => policy .WithOrigins(corsAllowedOrigins) .AllowAnyHeader() - .AllowAnyMethod()); + .AllowAnyMethod() + // Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа, + // а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов). + .WithExposedHeaders("Content-Disposition")); }); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); diff --git a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index ac24261..b663634 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -3,6 +3,9 @@ + + + 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..a8f04ff --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs @@ -0,0 +1,312 @@ +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 record RouteExportFile(byte[] Content, string FileName); + +/// +/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов +/// заявок, превращает их в понятные человеку строки (роль, исполнитель, +/// организация, условие, комментарий) и отдаёт на вёрстку в +/// . +/// +/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага, +/// ), у каждой своя нумерация. +/// + +public sealed class RouteExportService( + ApprovalRouteDbContext db, + ITnsuPortalClient portalClient, + IOptions portalOptions) +{ + private const int MaxTypesInFileName = 7; + private const int MaxTypeNameLength = 20; + + private const string Empty = "—"; + private const string Always = "Всегда"; + 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) + // Две вложенные коллекции (шаги и их условия) одним запросом дали бы декартово + // произведение строк — режем на отдельные запросы. + .AsSplitQuery() + .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(new RouteExportFile( + RouteExportWorkbook.Build(sheets), + FileName(types.Select(t => t.Name).ToList(), DateOnly.FromDateTime(DateTime.Now)))); + } + + /// + /// «Маршруты согласования (подбор, прием на работу) 13.10.2026.xlsx» — в скобках + /// сокращённые названия типов (не больше , остальные + /// сворачиваются в «и др.»), в конце — дата выгрузки. + /// + public static string FileName(IReadOnlyList typeNames, DateOnly date) + { + var shown = typeNames.Take(MaxTypesInFileName).Select(Abbreviate).Where(n => n.Length > 0).ToList(); + var list = string.Join(", ", shown) + (typeNames.Count > MaxTypesInFileName ? " и др." : string.Empty); + + var name = $"Маршруты согласования ({list}) {date:dd.MM.yyyy}.xlsx"; + return string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? ' ' : c)); + } + + // «Заявка на подбор персонала» → «подбор персонала»: служебное начало названия + // ничего не говорит, а длинное название раздуло бы имя файла. + private static string Abbreviate(string typeName) + { + var name = typeName.Trim(); + foreach (var prefix in new[] { "Заявка на ", "Заявка о ", "Заявка " }) + { + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + name = name[prefix.Length..]; + break; + } + } + + if (name.Length <= MaxTypeNameLength) + return name; + + var cut = name[..MaxTypeNameLength]; + var lastSpace = cut.LastIndexOf(' '); + return (lastSpace > MaxTypeNameLength / 2 ? cut[..lastSpace] : cut).TrimEnd() + "…"; + } + + 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 => + { + return new RouteExportSection( + routes.Count < 2 ? null : RouteTitle(route, directory), + BuildGroups(route.Steps, 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, 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, directory)), + new RouteExportGroup("Исполнители", BuildRows(executors, directory)) + } + .Where(g => g.Rows.Count > 0) + .ToList(); + } + + // Параллельные шаги (равный OrderNo) получают один и тот же номер. + private List BuildRows(IReadOnlyList steps, PortalDirectory directory) + { + var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList(); + + return steps + .Select(step => + { + var (role, executor, organization) = 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, string Organization) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory) + { + if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) + { + var employee = directory.FindByEmail(step.ExecutorEmail); + return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail, directory.CompaniesOf(step.ExecutorEmail)); + } + + if (!string.IsNullOrWhiteSpace(step.Position)) + { + var holder = directory.FindByPosition(step.Position); + return (step.Position, holder?.Fio ?? Empty, directory.CompaniesOf(holder?.Email)); + } + + if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None) + return (ProjectRoleLabels[projectRole], ByRequest, Empty); + + 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, directory.CompaniesOf(fixedEmail)); + + return (HeadTypeLabels[headType], ByRequest, Empty); + } + + return (Empty, 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)); + + // Организация — компания СОТРУДНИКА, а не маршрута. Справочник даёт строку на + // каждую компанию, где он числится (совместители), поэтому выводим все. + public string CompaniesOf(string? email) + { + if (string.IsNullOrWhiteSpace(email)) + return Empty; + + var names = employees + .Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(e.Company)) + .Select(e => e.Company) + .Distinct() + .ToList(); + return names.Count == 0 ? Empty : string.Join(", ", names); + } + + // Пока справочник не отдал имя (или запись удалена) — усечённый 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 new file mode 100644 index 0000000..14434c7 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs @@ -0,0 +1,172 @@ +using NPOI.SS.UserModel; +using NPOI.SS.Util; +using NPOI.XSSF.UserModel; + +namespace Tnsu.ApprovalRoute.Application.Reports; + +/// Строка выгрузки маршрута: шаг в понятном человеку виде. +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 Groups); + +/// Лист выгрузки — один тип заявки. +public sealed record RouteExportSheet(string Name, IReadOnlyList Sections); + +/// Тело запроса POST api/reports/routes — какие типы заявок выгрузить. +public sealed record RouteExportRequest(IReadOnlyList? RequestTypeIds); + +/// +/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: +/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». +/// +/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает +/// . +/// +public static class RouteExportWorkbook +{ + private const int MaxSheetNameLength = 31; + private const string InvalidSheetNameChars = "[]:*?/\\"; + + private static readonly (string Title, double Width)[] Columns = + [ + (" ", 6.0), + ("Роль ", 41.1), + ("Исполнитель ", 42.2), + ("Организация ", 28.0), + ("Условие ", 66.2), + ("Комментарий ", 36.0), + ]; + + public static byte[] Build(IReadOnlyList sheets) + { + var workbook = new XSSFWorkbook(); + + var headerStyle = CreateStyle(workbook, bold: true); + var cellStyle = CreateStyle(workbook, bold: false); + var titleStyle = CreateStyle(workbook, bold: true, border: false); + + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var sheetData in sheets) + { + var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames)); + for (var i = 0; i < Columns.Length; i++) + sheet.SetColumnWidth(i, (int)(Columns[i].Width * 256)); + + var rowIndex = 0; + foreach (var section in sheetData.Sections) + { + if (rowIndex > 0) rowIndex++; // пустая строка только между маршрутами + + if (!string.IsNullOrWhiteSpace(section.Title)) + { + var titleRow = sheet.CreateRow(rowIndex); + var titleCell = titleRow.CreateCell(0); + titleCell.SetCellValue(section.Title); + titleCell.CellStyle = titleStyle; + sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1)); + rowIndex++; + } + + // Шапка колонок — один раз на маршрут: исполнители идут сразу под + // согласующими, их отделяет только строка-подпись. + var firstGroup = true; + foreach (var group in section.Groups) + { + 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++; + + if (firstGroup) + { + 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; + } + } + + firstGroup = false; + + 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); + } + } + } + } + + using var stream = new MemoryStream(); + workbook.Write(stream, leaveOpen: true); + return stream.ToArray(); + } + + private static void WriteText(IRow row, int column, string value, ICellStyle style) + { + var cell = row.CreateCell(column); + cell.SetCellValue(value); + cell.CellStyle = style; + } + + private static ICellStyle CreateStyle(IWorkbook workbook, bool bold, bool border = true) + { + var font = workbook.CreateFont(); + font.IsBold = bold; + + var style = workbook.CreateCellStyle(); + style.SetFont(font); + style.WrapText = true; + style.VerticalAlignment = VerticalAlignment.Top; + if (border) + { + style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin; + } + return style; + } + + // Имя листа Excel: до 31 символа, без []:*?/\, уникальное без учёта регистра. + private static string UniqueSheetName(string raw, HashSet used) + { + var cleaned = new string((raw ?? string.Empty) + .Select(c => InvalidSheetNameChars.Contains(c) ? ' ' : c) + .ToArray()).Trim(); + if (cleaned.Length == 0) cleaned = "Лист"; + + var candidate = Truncate(cleaned, MaxSheetNameLength); + for (var n = 2; !used.Add(candidate); n++) + { + var suffix = $" ({n})"; + candidate = Truncate(cleaned, MaxSheetNameLength - suffix.Length) + suffix; + } + return candidate; + } + + private static string Truncate(string value, int length) => + value.Length <= length ? value : value[..length].TrimEnd(); +} 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 efaa5b9..c2bab51 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -20,6 +20,7 @@ import { RouteStepsScreen } from '../screens/RouteStepsScreen'; import { ResolveScreen } from '../screens/ResolveScreen'; import { ManageRequestsScreen } from '../screens/ManageRequestsScreen'; import { ManageByUserScreen } from '../screens/ManageByUserScreen'; +import { RoutesExportScreen } from '../screens/RoutesExportScreen'; // Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый // стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не @@ -28,11 +29,12 @@ import { ManageByUserScreen } from '../screens/ManageByUserScreen'; const NAV_ITEMS: ISidebarItem[] = [ { id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' }, { id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' }, - { id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' }, { id: 'routes', title: texts.nav.routes, icon: 'send' }, { id: 'resolve', title: texts.nav.resolve, icon: 'search' }, + { id: 'routesExport', title: texts.nav.routesExport, icon: 'document' }, { id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' }, - { id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' } + { id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' }, + { id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' } ]; function sidebarActiveId(tab: ScreenKey): string { @@ -184,6 +186,7 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint route={route.resolve} /> )} + {route.tab === 'routesExport' && } {route.tab === 'manageRequests' && ( )} diff --git a/frontend/src/webparts/approvalAdmin/components/app/types.ts b/frontend/src/webparts/approvalAdmin/components/app/types.ts index 0f17ad6..2af6f6c 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/types.ts +++ b/frontend/src/webparts/approvalAdmin/components/app/types.ts @@ -5,5 +5,6 @@ export type ScreenKey = | 'routes' | 'routeSteps' | 'resolve' + | 'routesExport' | 'manageRequests' | 'manageByUser'; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.module.scss index 02a7fd2..1b6dd1b 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.module.scss @@ -10,3 +10,24 @@ max-width: 320px; flex: 1; } + +.chosen { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 10px; +} + +.chosenItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 4px 8px 4px 12px; + border: 1px solid #e1e4ea; + border-radius: 6px; +} + +.chosenEmpty { + opacity: 0.6; +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx index 67ef86d..8faecc3 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx @@ -53,7 +53,7 @@ export const RequestTypeAccessScreen: React.FC = const [showForm, setShowForm] = React.useState(false); const [requestTypeId, setRequestTypeId] = React.useState(''); - const [email, setEmail] = React.useState(''); + const [emails, setEmails] = React.useState([]); const submit = useAsyncSubmit(); const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, ''); @@ -97,7 +97,7 @@ export const RequestTypeAccessScreen: React.FC = const openCreate = (): void => { setRequestTypeId(''); - setEmail(''); + setEmails([]); setShowForm(true); }; @@ -106,17 +106,56 @@ export const RequestTypeAccessScreen: React.FC = submit.fail(texts.requestTypeAccess.validationRequestType); return; } - if (!email.trim()) { + if (emails.length === 0) { submit.fail(texts.requestTypeAccess.validationEmail); return; } - submit.run(() => - api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email: email.trim() }).then(() => { + // По одному запросу на сотрудника (бэкенд выдаёт доступ по одному). Падение на + // одном не отменяет остальных: неудавшиеся остаются в списке с причиной, чтобы + // можно было поправить и нажать ещё раз. + submit.run(async () => { + const failures: string[] = []; + const failedEmails: string[] = []; + for (const email of emails) { + try { + await api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email }); + } catch (e) { + failedEmails.push(email); + failures.push(`${email}: ${(e as Error).message}`); + } + } + + if (failedEmails.length < emails.length) access.reload(); + if (failures.length === 0) { setShowForm(false); - access.reload(); - }) - ); + return; + } + setEmails(failedEmails); + throw new Error(texts.requestTypeAccess.createFailed(failures)); + }); + }; + + // Тех, у кого доступ к этому типу уже есть (и кого уже добавили в список), показываем + // в поиске с пометкой, но выбрать нельзя — так видно, что человек не потерялся. + const grantedEmails = new Set( + access.items.filter(a => String(a.requestTypeId) === requestTypeId).map(a => a.email.toLowerCase()) + ); + const chosenEmails = new Set(emails.map(e => e.toLowerCase())); + const employeeOptions = employees.items.map(employee => { + const option = toEmployeeOption(employee); + const key = employee.email.toLowerCase(); + if (grantedEmails.has(key)) { + return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyGranted}` }; + } + if (chosenEmails.has(key)) { + return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyChosen}` }; + } + return option; + }); + const employeeLabel = (email: string): string => { + const employee = employees.items.find(e => e.email.toLowerCase() === email.toLowerCase()); + return employee ? `${employee.fio} · ${employee.email}` : email; }; return ( @@ -207,15 +246,30 @@ export const RequestTypeAccessScreen: React.FC = /> - +
+ value && setEmails(current => [...current, value])} + /> +
+ {emails.length === 0 && {texts.requestTypeAccess.noEmailsSelected}} + {emails.map(email => ( +
+ {employeeLabel(email)} + setEmails(current => current.filter(x => x !== email))} + /> +
+ ))} +
+
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx new file mode 100644 index 0000000..1aa3daf --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { ApprovalRouteApi } from '../../services/api'; +import { texts } from '../../texts'; +import { useRequestTypes } from '../../hooks/useRequestTypes'; +import { useLocalStorageState } from '../../hooks/useLocalStorageState'; +import { reportError } from '../../utils/errors'; +import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui'; + +export interface IRoutesExportScreenProps { + api: ApprovalRouteApi; +} + +// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно +// делают по одному и тому же набору типов. +const SELECTED_STORAGE_KEY = 'approvalAdmin.routesExport.selected'; + +function download(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export const RoutesExportScreen: React.FC = ({ api }) => { + const requestTypes = useRequestTypes(api); + + const [storedSelected, setSelected] = useLocalStorageState(SELECTED_STORAGE_KEY, []); + const [isBusy, setIsBusy] = React.useState(false); + const [error, setError] = React.useState(); + + const allIds = requestTypes.items.map(rt => rt.id); + // Удалённые с тех пор типы из сохранённого выбора не считаем. + const selected = storedSelected.filter(id => allIds.indexOf(id) !== -1); + const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1); + + const toggle = (id: number, checked: boolean): void => + setSelected(current => (checked ? [...current.filter(x => x !== id), id] : current.filter(x => x !== id))); + + const runExport = (): void => { + setError(undefined); + setIsBusy(true); + api.reports.routes(selected) + .then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName)) + .catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export')))) + .then(() => setIsBusy(false), () => setIsBusy(false)); + }; + + return ( +
+ + + {isBusy ? texts.routesExport.exporting : texts.routesExport.export} + + } + /> + + {requestTypes.error && {requestTypes.error}} + {error && {error}} + + +

{texts.routesExport.hint}

+ {requestTypes.isLoading &&

{texts.common.loading}

} + {!requestTypes.isLoading && requestTypes.items.length === 0 &&

{texts.routesExport.emptyTypes}

} + {requestTypes.items.length > 0 && ( +
+ setSelected(checked ? allIds : [])} + /> + {requestTypes.items.map(rt => ( + toggle(rt.id, checked)} + /> + ))} +

{texts.routesExport.selected(selected.length)}

+
+ )} +
+
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss index 7329cda..01fd05e 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss @@ -116,6 +116,7 @@ &:hover, &.active { background: $gold-50; color: $navy-900; } &.selected { font-weight: 600; color: $navy-900; } + &:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; } } .itemSub { diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx index 055e0e9..0e9fbd6 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx @@ -9,6 +9,8 @@ export interface ISearchableOption { label: string; /** Второстепенная строка под названием — например, компания у отдела. */ sublabel?: string; + /** Показывается в списке (и находится поиском), но выбрать нельзя. */ + disabled?: boolean; } export interface ISearchableSelectProps { @@ -135,6 +137,7 @@ export const SearchableSelect: React.FC = ({ type="button" key={opt.value} className={`${styles.item} ${opt.value === value ? styles.selected : ''}`} + disabled={opt.disabled} onClick={() => pick(opt.value)} > {opt.label} diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index fe8ba58..a094c36 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -89,7 +89,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change'; // 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а // состояние вкладки 'routes' при наличии aaroute (см. parseRoute). -const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'routes', 'resolve', 'manageRequests', 'manageByUser']; +const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'routesExport', 'manageRequests', 'manageByUser', 'requestTypeAccess']; 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 1c041f1..b6ca4e1 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -1,4 +1,4 @@ -import { IHttpClient } from './httpClient'; +import { IDownloadedFile, IHttpClient } from './httpClient'; import { CreateRequestTypeAccessInput, CreateRequestTypeInput, @@ -172,4 +172,10 @@ export class ApprovalRouteApi { }) .then(rows => rows.map(toUserApprovalRow)) }; + + // Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON. + public readonly reports = { + routes: (requestTypeIds: number[]): Promise => + this.http.postForBlob('/api/reports/routes', { requestTypeIds }) + }; } diff --git a/frontend/src/webparts/approvalAdmin/services/httpClient.ts b/frontend/src/webparts/approvalAdmin/services/httpClient.ts index 399b1e5..ed947e4 100644 --- a/frontend/src/webparts/approvalAdmin/services/httpClient.ts +++ b/frontend/src/webparts/approvalAdmin/services/httpClient.ts @@ -18,6 +18,28 @@ export class ApiClientError extends Error { } } +/** Скачанный файл; fileName пуст, если сервер не прислал Content-Disposition. */ +export interface IDownloadedFile { + blob: Blob; + fileName?: string; +} + +// filename*=UTF-8''... (RFC 5987, так отдаёт ASP.NET для кириллицы) или обычный filename=... +// eslint-disable-next-line @rushstack/no-new-null -- header — результат Headers.get() (DOM API) +function fileNameFromDisposition(header: string | null | undefined): string | undefined { + if (!header) return undefined; + const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header); + if (encoded) { + try { + return decodeURIComponent(encoded[1]); + } catch { + // Битая кодировка — падаем на обычный filename ниже. + } + } + const plain = /filename="?([^";]+)"?/i.exec(header); + return plain ? plain[1] : undefined; +} + export type QueryParams = Record; export interface IHttpClient { @@ -26,6 +48,8 @@ export interface IHttpClient { put(path: string, body?: unknown): Promise; patch(path: string, body?: unknown): Promise; delete(path: string): Promise; + /** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */ + postForBlob(path: string, body: unknown): Promise; } function buildUrl(baseUrl: string, path: string, query?: QueryParams): string { @@ -105,6 +129,21 @@ export class AadApiHttpClient implements IHttpClient { await this.send('DELETE', path); } + public async postForBlob(path: string, body: unknown): Promise { + const url = buildUrl(this.baseUrl, path); + const response = await this.client.fetch(url, AadHttpClient.configurations.v1, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!response.ok) { + // Ошибка приходит обычным JSON-конвертом ({ messages } или { errors }) — разбираем тем же парсером. + await parseResponse(response); + throw new ApiClientError(response.status, [`HTTP ${response.status}`]); + } + return { blob: await response.blob(), fileName: fileNameFromDisposition(response.headers.get('Content-Disposition')) }; + } + private send(method: string, path: string, body?: unknown): Promise { const url = buildUrl(this.baseUrl, path); return this.client diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index b6048f2..ba2f123 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -96,6 +96,7 @@ export const texts = { requestTypeAccess: 'Доступ к заявкам', routes: 'Маршруты', resolve: 'Проверка маршрута', + routesExport: 'Выгрузка маршрутов', manageRequests: 'Редактирование заявок', manageByUser: 'Заявки по участнику' }, @@ -197,15 +198,35 @@ export const texts = { createTitle: 'Выдать доступ', requestTypeLabel: 'Тип заявки', requestTypePlaceholder: 'Выберите тип заявки', - emailLabel: 'Сотрудник', - emailPlaceholder: 'Найдите по ФИО или email', + emailLabel: 'Сотрудники', + emailPlaceholder: 'Добавьте сотрудника — найдите по ФИО или email', + removeEmail: 'Убрать из списка', + alreadyGranted: 'доступ уже есть', + alreadyChosen: 'уже в списке', + noEmailsSelected: 'Пока никого не выбрано.', + createFailed: (failures: string[]): string => + `Не удалось выдать доступ (остались в списке): ${failures.join('; ')}`, validationRequestType: 'Выберите тип заявки.', - validationEmail: 'Выберите сотрудника.', + validationEmail: 'Выберите хотя бы одного сотрудника.', deleteTitle: 'Отозвать доступ?', deleteText: (email: string, requestTypeName: string): string => `${email} больше не будет видеть все заявки типа «${requestTypeName}».` }, + routesExport: { + breadcrumb: 'Выгрузка маршрутов', + subtitle: 'Маршруты согласования в Excel', + title: 'Выгрузка маршрутов', + hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (согласующие и исполнители отдельными таблицами).', + selectAll: 'Выбрать все', + export: 'Выгрузить', + exporting: 'Формирование файла...', + emptyTypes: 'Типов заявок пока нет.', + selected: (count: number): string => `Выбрано: ${count}`, + fileName: 'Маршруты согласования.xlsx', + failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}` + }, + routes: { breadcrumb: 'Маршруты согласования', subtitle: 'Настройка согласований',