Feature/routes export (#29)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/29
This commit was merged in pull request #29.
This commit is contained in:
@@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = AuthPolicies.Any)]
|
[Authorize(Policy = AuthPolicies.Any)]
|
||||||
[Route("api/reports")]
|
[Route("api/reports")]
|
||||||
public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase
|
public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string XlsxContentType =
|
private const string XlsxContentType =
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
@@ -51,4 +51,27 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll
|
|||||||
|
|
||||||
return File(file, XlsxContentType);
|
return File(file, XlsxContentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
||||||
|
/// POST — потому что в теле список id типов (см. RouteExportService).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("routes")]
|
||||||
|
[Produces(XlsxContentType)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ builder.Services.AddCors(options =>
|
|||||||
options.AddPolicy(CorsPolicyName, policy => policy
|
options.AddPolicy(CorsPolicyName, policy => policy
|
||||||
.WithOrigins(corsAllowedOrigins)
|
.WithOrigins(corsAllowedOrigins)
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod());
|
.AllowAnyMethod()
|
||||||
|
// Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа,
|
||||||
|
// а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов).
|
||||||
|
.WithExposedHeaders("Content-Disposition"));
|
||||||
});
|
});
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="appsettings.*.json" DependentUpon="appsettings.json" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" />
|
<PackageReference Include="Hangfire.AspNetCore" />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddScoped<AutoApproveService>();
|
services.AddScoped<AutoApproveService>();
|
||||||
services.AddScoped<AutoApproveJob>();
|
services.AddScoped<AutoApproveJob>();
|
||||||
services.AddScoped<VacancyReportService>();
|
services.AddScoped<VacancyReportService>();
|
||||||
|
services.AddScoped<RouteExportService>();
|
||||||
services.AddScoped<TemplateRouteService>();
|
services.AddScoped<TemplateRouteService>();
|
||||||
services.AddScoped<TemplateRouteStepService>();
|
services.AddScoped<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Готовый файл выгрузки и его имя.</summary>
|
||||||
|
public sealed record RouteExportFile(byte[] Content, string FileName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов
|
||||||
|
/// заявок, превращает их в понятные человеку строки (роль, исполнитель,
|
||||||
|
/// организация, условие, комментарий) и отдаёт на вёрстку в
|
||||||
|
/// <see cref="RouteExportWorkbook"/>.
|
||||||
|
///
|
||||||
|
/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага,
|
||||||
|
/// <see cref="RouteStepStatus"/>), у каждой своя нумерация.
|
||||||
|
/// </summary>
|
||||||
|
|
||||||
|
public sealed class RouteExportService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ITnsuPortalClient portalClient,
|
||||||
|
IOptions<TnsuPortalOptions> 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 = "Определяется по заявке";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Технические имена колонок списка, которые в таблице показываем по-человечески
|
||||||
|
/// (CompaniesJson — организации заявки).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string> ConditionFieldAliases = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["CompaniesJson"] = "организация"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<ProjectRole, string> ProjectRoleLabels = new()
|
||||||
|
{
|
||||||
|
[ProjectRole.ProjectManager] = "Руководитель проекта",
|
||||||
|
[ProjectRole.Planner] = "Планировщик",
|
||||||
|
[ProjectRole.ManagerHSE] = "Менеджер HSE",
|
||||||
|
[ProjectRole.Security] = "Безопасность",
|
||||||
|
[ProjectRole.Keeper] = "Заведующий склада"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<HeadType, string> HeadTypeLabels = new()
|
||||||
|
{
|
||||||
|
[HeadType.Chief] = "Непосредственный руководитель",
|
||||||
|
[HeadType.DirectionHead] = "Руководитель направления",
|
||||||
|
[HeadType.DirectorTansu] = "Директор Tansu",
|
||||||
|
[HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu",
|
||||||
|
[HeadType.DepartmentHead] = "Руководитель указанного отдела",
|
||||||
|
[HeadType.CompanyDirectors] = "Директора компаний инициатора"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new()
|
||||||
|
{
|
||||||
|
[ComparisonOperator.GreaterThan] = ">",
|
||||||
|
[ComparisonOperator.LessThan] = "<",
|
||||||
|
[ComparisonOperator.Equal] = "=",
|
||||||
|
[ComparisonOperator.NotEqual] = "≠",
|
||||||
|
[ComparisonOperator.Contains] = "содержит",
|
||||||
|
[ComparisonOperator.NotContains] = "не содержит"
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<Result<RouteExportFile>> BuildAsync(IReadOnlyList<int>? 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))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// «Маршруты согласования (подбор, прием на работу) 13.10.2026.xlsx» — в скобках
|
||||||
|
/// сокращённые названия типов (не больше <see cref="MaxTypesInFileName"/>, остальные
|
||||||
|
/// сворачиваются в «и др.»), в конце — дата выгрузки.
|
||||||
|
/// </summary>
|
||||||
|
public static string FileName(IReadOnlyList<string> 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<PortalDirectory> 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<TemplateRoute> 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<RouteExportGroup> BuildGroups(IEnumerable<TemplateRouteStep> 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<RouteExportRow> BuildRows(IReadOnlyList<TemplateRouteStep> 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<EmployeeContactInfo> employees,
|
||||||
|
IReadOnlyDictionary<Guid, string> companies,
|
||||||
|
IReadOnlyDictionary<Guid, string> 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using NPOI.SS.UserModel;
|
||||||
|
using NPOI.SS.Util;
|
||||||
|
using NPOI.XSSF.UserModel;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
|
||||||
|
public sealed record RouteExportRow(
|
||||||
|
int Number,
|
||||||
|
string Role,
|
||||||
|
string Executor,
|
||||||
|
string Organization,
|
||||||
|
string Condition,
|
||||||
|
string Comment);
|
||||||
|
|
||||||
|
/// <summary>Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация).</summary>
|
||||||
|
public sealed record RouteExportGroup(string Caption, IReadOnlyList<RouteExportRow> Rows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
|
||||||
|
/// (по компании/отделу) и их нужно различать на одном листе; у единственного
|
||||||
|
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
|
||||||
|
|
||||||
|
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
||||||
|
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
|
||||||
|
|
||||||
|
/// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
|
||||||
|
public sealed record RouteExportRequest(IReadOnlyList<int>? RequestTypeIds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
||||||
|
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
||||||
|
///
|
||||||
|
/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
|
||||||
|
/// <see cref="RouteExportService"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<RouteExportSheet> 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<string>(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<string> 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();
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
|
|||||||
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
|
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken));
|
||||||
|
|
||||||
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync(
|
GetOrAddAsync(
|
||||||
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
||||||
|
|||||||
@@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
|
|
||||||
return employees
|
return employees
|
||||||
.Where(e => !string.IsNullOrWhiteSpace(e.Fio))
|
.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<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var companies = await GetAsync<List<DictionaryItemResponse>>("/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<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var departments = await GetAsync<List<DictionaryItemResponse>>("/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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
[property: JsonPropertyName("fio")] string? Fio,
|
[property: JsonPropertyName("fio")] string? Fio,
|
||||||
[property: JsonPropertyName("id")] Guid? Id = null);
|
[property: JsonPropertyName("id")] Guid? Id = null);
|
||||||
|
|
||||||
|
private sealed record DictionaryItemResponse(
|
||||||
|
[property: JsonPropertyName("id")] Guid? Id,
|
||||||
|
[property: JsonPropertyName("name")] string? Name);
|
||||||
|
|
||||||
private sealed record ManagerResponse(
|
private sealed record ManagerResponse(
|
||||||
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ public interface ITnsuPortalClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Справочник компаний — /Dictionary/Companies.</summary>
|
||||||
|
Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Справочник отделов — /Dictionary/Departments.</summary>
|
||||||
|
Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company,
|
|||||||
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
|
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
|
||||||
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
|
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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);
|
||||||
|
|
||||||
|
/// <summary>Компания из справочника /Dictionary/Companies.</summary>
|
||||||
|
public sealed record CompanyInfo(Guid Id, string Name);
|
||||||
|
|
||||||
|
/// <summary>Отдел из справочника /Dictionary/Departments.</summary>
|
||||||
|
public sealed record DepartmentInfo(Guid Id, string Name);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
|||||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||||
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||||
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
||||||
|
import { RoutesExportScreen } from '../screens/RoutesExportScreen';
|
||||||
|
|
||||||
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
||||||
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
||||||
@@ -28,11 +29,12 @@ import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
|||||||
const NAV_ITEMS: ISidebarItem[] = [
|
const NAV_ITEMS: ISidebarItem[] = [
|
||||||
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
||||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
{ 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: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
{ 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: '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 {
|
function sidebarActiveId(tab: ScreenKey): string {
|
||||||
@@ -184,6 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
route={route.resolve}
|
route={route.resolve}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
|
||||||
{route.tab === 'manageRequests' && (
|
{route.tab === 'manageRequests' && (
|
||||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export type ScreenKey =
|
|||||||
| 'routes'
|
| 'routes'
|
||||||
| 'routeSteps'
|
| 'routeSteps'
|
||||||
| 'resolve'
|
| 'resolve'
|
||||||
|
| 'routesExport'
|
||||||
| 'manageRequests'
|
| 'manageRequests'
|
||||||
| 'manageByUser';
|
| 'manageByUser';
|
||||||
|
|||||||
+21
@@ -10,3 +10,24 @@
|
|||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
flex: 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+71
-17
@@ -53,7 +53,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
|
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const [requestTypeId, setRequestTypeId] = React.useState('');
|
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||||
const [email, setEmail] = React.useState('');
|
const [emails, setEmails] = React.useState<string[]>([]);
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
||||||
@@ -97,7 +97,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setRequestTypeId('');
|
setRequestTypeId('');
|
||||||
setEmail('');
|
setEmails([]);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,17 +106,56 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
submit.fail(texts.requestTypeAccess.validationRequestType);
|
submit.fail(texts.requestTypeAccess.validationRequestType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!email.trim()) {
|
if (emails.length === 0) {
|
||||||
submit.fail(texts.requestTypeAccess.validationEmail);
|
submit.fail(texts.requestTypeAccess.validationEmail);
|
||||||
return;
|
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);
|
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 (
|
return (
|
||||||
@@ -207,15 +246,30 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
||||||
<SearchableSelect
|
<div>
|
||||||
options={employees.items.map(toEmployeeOption)}
|
<SearchableSelect
|
||||||
value={email}
|
options={employeeOptions}
|
||||||
loading={employees.isLoading}
|
value=""
|
||||||
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
loading={employees.isLoading}
|
||||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||||
emptyMessage={texts.select.employeesEmpty}
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
onChange={setEmail}
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
/>
|
onChange={value => value && setEmails(current => [...current, value])}
|
||||||
|
/>
|
||||||
|
<div className={styles.chosen}>
|
||||||
|
{emails.length === 0 && <span className={styles.chosenEmpty}>{texts.requestTypeAccess.noEmailsSelected}</span>}
|
||||||
|
{emails.map(email => (
|
||||||
|
<div key={email} className={styles.chosenItem}>
|
||||||
|
<span>{employeeLabel(email)}</span>
|
||||||
|
<IconButton
|
||||||
|
icon="close"
|
||||||
|
label={texts.requestTypeAccess.removeEmail}
|
||||||
|
onClick={() => setEmails(current => current.filter(x => x !== email))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -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<IRoutesExportScreenProps> = ({ api }) => {
|
||||||
|
const requestTypes = useRequestTypes(api);
|
||||||
|
|
||||||
|
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
|
||||||
|
const [isBusy, setIsBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
|
||||||
|
<PageHeader
|
||||||
|
icon="clipboard"
|
||||||
|
subtitle={texts.routesExport.subtitle}
|
||||||
|
title={texts.routesExport.title}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
icon="check"
|
||||||
|
disabled={selected.length === 0 || isBusy}
|
||||||
|
onClick={runExport}
|
||||||
|
>
|
||||||
|
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||||
|
{error && <Alert kind="err">{error}</Alert>}
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<p>{texts.routesExport.hint}</p>
|
||||||
|
{requestTypes.isLoading && <p>{texts.common.loading}</p>}
|
||||||
|
{!requestTypes.isLoading && requestTypes.items.length === 0 && <p>{texts.routesExport.emptyTypes}</p>}
|
||||||
|
{requestTypes.items.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
label={texts.routesExport.selectAll}
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={checked => setSelected(checked ? allIds : [])}
|
||||||
|
/>
|
||||||
|
{requestTypes.items.map(rt => (
|
||||||
|
<Checkbox
|
||||||
|
key={rt.id}
|
||||||
|
label={rt.name}
|
||||||
|
checked={selected.indexOf(rt.id) !== -1}
|
||||||
|
onChange={checked => toggle(rt.id, checked)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<p>{texts.routesExport.selected(selected.length)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -116,6 +116,7 @@
|
|||||||
|
|
||||||
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
||||||
&.selected { font-weight: 600; color: $navy-900; }
|
&.selected { font-weight: 600; color: $navy-900; }
|
||||||
|
&:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemSub {
|
.itemSub {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface ISearchableOption {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Второстепенная строка под названием — например, компания у отдела. */
|
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
|
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISearchableSelectProps {
|
export interface ISearchableSelectProps {
|
||||||
@@ -135,6 +137,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||||
|
disabled={opt.disabled}
|
||||||
onClick={() => pick(opt.value)}
|
onClick={() => pick(opt.value)}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
|||||||
|
|
||||||
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
||||||
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
// состояние вкладки '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 = [
|
const PARAM_KEYS = [
|
||||||
'aatab', 'aaroute', 'aarfilter',
|
'aatab', 'aaroute', 'aarfilter',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IHttpClient } from './httpClient';
|
import { IDownloadedFile, IHttpClient } from './httpClient';
|
||||||
import {
|
import {
|
||||||
CreateRequestTypeAccessInput,
|
CreateRequestTypeAccessInput,
|
||||||
CreateRequestTypeInput,
|
CreateRequestTypeInput,
|
||||||
@@ -172,4 +172,10 @@ export class ApprovalRouteApi {
|
|||||||
})
|
})
|
||||||
.then(rows => rows.map(toUserApprovalRow))
|
.then(rows => rows.map(toUserApprovalRow))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
|
||||||
|
public readonly reports = {
|
||||||
|
routes: (requestTypeIds: number[]): Promise<IDownloadedFile> =>
|
||||||
|
this.http.postForBlob('/api/reports/routes', { requestTypeIds })
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string | number | boolean | undefined>;
|
export type QueryParams = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
export interface IHttpClient {
|
export interface IHttpClient {
|
||||||
@@ -26,6 +48,8 @@ export interface IHttpClient {
|
|||||||
put<T>(path: string, body?: unknown): Promise<T>;
|
put<T>(path: string, body?: unknown): Promise<T>;
|
||||||
patch<T>(path: string, body?: unknown): Promise<T>;
|
patch<T>(path: string, body?: unknown): Promise<T>;
|
||||||
delete(path: string): Promise<void>;
|
delete(path: string): Promise<void>;
|
||||||
|
/** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */
|
||||||
|
postForBlob(path: string, body: unknown): Promise<IDownloadedFile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
||||||
@@ -105,6 +129,21 @@ export class AadApiHttpClient implements IHttpClient {
|
|||||||
await this.send<void>('DELETE', path);
|
await this.send<void>('DELETE', path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async postForBlob(path: string, body: unknown): Promise<IDownloadedFile> {
|
||||||
|
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<void>(response);
|
||||||
|
throw new ApiClientError(response.status, [`HTTP ${response.status}`]);
|
||||||
|
}
|
||||||
|
return { blob: await response.blob(), fileName: fileNameFromDisposition(response.headers.get('Content-Disposition')) };
|
||||||
|
}
|
||||||
|
|
||||||
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const url = buildUrl(this.baseUrl, path);
|
const url = buildUrl(this.baseUrl, path);
|
||||||
return this.client
|
return this.client
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export const texts = {
|
|||||||
requestTypeAccess: 'Доступ к заявкам',
|
requestTypeAccess: 'Доступ к заявкам',
|
||||||
routes: 'Маршруты',
|
routes: 'Маршруты',
|
||||||
resolve: 'Проверка маршрута',
|
resolve: 'Проверка маршрута',
|
||||||
|
routesExport: 'Выгрузка маршрутов',
|
||||||
manageRequests: 'Редактирование заявок',
|
manageRequests: 'Редактирование заявок',
|
||||||
manageByUser: 'Заявки по участнику'
|
manageByUser: 'Заявки по участнику'
|
||||||
},
|
},
|
||||||
@@ -197,15 +198,35 @@ export const texts = {
|
|||||||
createTitle: 'Выдать доступ',
|
createTitle: 'Выдать доступ',
|
||||||
requestTypeLabel: 'Тип заявки',
|
requestTypeLabel: 'Тип заявки',
|
||||||
requestTypePlaceholder: 'Выберите тип заявки',
|
requestTypePlaceholder: 'Выберите тип заявки',
|
||||||
emailLabel: 'Сотрудник',
|
emailLabel: 'Сотрудники',
|
||||||
emailPlaceholder: 'Найдите по ФИО или email',
|
emailPlaceholder: 'Добавьте сотрудника — найдите по ФИО или email',
|
||||||
|
removeEmail: 'Убрать из списка',
|
||||||
|
alreadyGranted: 'доступ уже есть',
|
||||||
|
alreadyChosen: 'уже в списке',
|
||||||
|
noEmailsSelected: 'Пока никого не выбрано.',
|
||||||
|
createFailed: (failures: string[]): string =>
|
||||||
|
`Не удалось выдать доступ (остались в списке): ${failures.join('; ')}`,
|
||||||
validationRequestType: 'Выберите тип заявки.',
|
validationRequestType: 'Выберите тип заявки.',
|
||||||
validationEmail: 'Выберите сотрудника.',
|
validationEmail: 'Выберите хотя бы одного сотрудника.',
|
||||||
deleteTitle: 'Отозвать доступ?',
|
deleteTitle: 'Отозвать доступ?',
|
||||||
deleteText: (email: string, requestTypeName: string): string =>
|
deleteText: (email: string, requestTypeName: string): string =>
|
||||||
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
|
`${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: {
|
routes: {
|
||||||
breadcrumb: 'Маршруты согласования',
|
breadcrumb: 'Маршруты согласования',
|
||||||
subtitle: 'Настройка согласований',
|
subtitle: 'Настройка согласований',
|
||||||
|
|||||||
Reference in New Issue
Block a user