Compare commits
1 Commits
main
..
6a01101054
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a01101054 |
@@ -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, RouteExportService routeExport) : ControllerBase
|
public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string XlsxContentType =
|
private const string XlsxContentType =
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
@@ -54,24 +54,24 @@ public sealed class ReportsController(VacancyReportService vacancies, RouteExpor
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
||||||
/// POST — потому что в теле список id типов (см. RouteExportService).
|
/// POST, потому что строки собирает админка — у неё уже есть справочники
|
||||||
|
/// сотрудников и подписи шагов (см. RouteExportWorkbook).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("routes")]
|
[HttpPost("routes")]
|
||||||
[Produces(XlsxContentType)]
|
[Produces(XlsxContentType)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<IActionResult> Routes([FromBody] RouteExportRequest request, CancellationToken ct = default)
|
public IActionResult Routes([FromBody] RouteExportRequest request)
|
||||||
{
|
{
|
||||||
var result = await routeExport.BuildAsync(request.RequestTypeIds, ct);
|
if (request.Sheets is not { Count: > 0 })
|
||||||
if (result.IsFailed)
|
return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } });
|
||||||
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
|
|
||||||
|
|
||||||
var file = result.Value;
|
var file = RouteExportWorkbook.Build(request);
|
||||||
|
|
||||||
var disposition = new ContentDispositionHeaderValue("attachment");
|
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||||
disposition.SetHttpFileName(file.FileName);
|
disposition.SetHttpFileName(RouteExportWorkbook.FileName);
|
||||||
Response.Headers.ContentDisposition = disposition.ToString();
|
Response.Headers.ContentDisposition = disposition.ToString();
|
||||||
|
|
||||||
return File(file.Content, XlsxContentType);
|
return File(file, XlsxContentType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,10 +53,7 @@ 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,9 +3,6 @@
|
|||||||
<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,7 +28,6 @@ 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>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Русские названия типов руководителя и ролей в проекте — для всего, что видит
|
|
||||||
/// человек: предупреждения резолва, ошибки валидации, выгрузка маршрутов. Имя
|
|
||||||
/// enum'а («DirectionHead») в тексте для пользователя недопустимо.
|
|
||||||
/// </summary>
|
|
||||||
public static class StepLabels
|
|
||||||
{
|
|
||||||
public static string Of(HeadType headType) => headType switch
|
|
||||||
{
|
|
||||||
HeadType.Chief => "Непосредственный руководитель",
|
|
||||||
HeadType.DirectionHead => "Руководитель направления",
|
|
||||||
HeadType.DirectorTansu => "Директор Tansu",
|
|
||||||
HeadType.ManagingDirectorTansu => "Управляющий директор Tansu",
|
|
||||||
HeadType.DepartmentHead => "Руководитель указанного отдела",
|
|
||||||
HeadType.CompanyDirectors => "Директора компаний инициатора",
|
|
||||||
_ => headType.ToString()
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Название с маленькой буквы — для середины фразы («Не найден руководитель направления»).</summary>
|
|
||||||
public static string InSentence(HeadType headType) => Lower(Of(headType));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Все названия мужского рода («найден руководитель проекта»), кроме «Безопасность» —
|
|
||||||
/// для неё нужна отдельная формулировка, иначе выйдет «найден безопасность».
|
|
||||||
/// </summary>
|
|
||||||
public static string InSentence(ProjectRole projectRole) =>
|
|
||||||
projectRole == ProjectRole.Security ? "сотрудник службы безопасности" : Lower(Of(projectRole));
|
|
||||||
|
|
||||||
private static string Lower(string label) => char.ToLowerInvariant(label[0]) + label[1..];
|
|
||||||
|
|
||||||
public static string Of(ProjectRole projectRole) => projectRole switch
|
|
||||||
{
|
|
||||||
ProjectRole.ProjectManager => "Руководитель проекта",
|
|
||||||
ProjectRole.Planner => "Планировщик",
|
|
||||||
ProjectRole.ManagerHSE => "Менеджер HSE",
|
|
||||||
ProjectRole.Security => "Безопасность",
|
|
||||||
ProjectRole.Keeper => "Заведующий склада",
|
|
||||||
_ => projectRole.ToString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common;
|
|
||||||
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<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 (StepLabels.Of(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 (StepLabels.Of(headType), directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
|
|
||||||
|
|
||||||
return (StepLabels.Of(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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,37 +4,28 @@ using NPOI.XSSF.UserModel;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
|
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка).</summary>
|
||||||
public sealed record RouteExportRow(
|
public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition);
|
||||||
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>
|
||||||
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
|
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
|
||||||
/// (по компании/отделу) и их нужно различать на одном листе; у единственного
|
/// (по компании/отделу) и их нужно различать на одном листе; у единственного
|
||||||
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
|
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
|
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportRow> Rows);
|
||||||
|
|
||||||
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
||||||
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
|
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
|
||||||
|
|
||||||
/// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
|
public sealed record RouteExportRequest(IReadOnlyList<RouteExportSheet> Sheets);
|
||||||
public sealed record RouteExportRequest(IReadOnlyList<int>? RequestTypeIds);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
||||||
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
||||||
///
|
///
|
||||||
/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
|
/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки:
|
||||||
/// <see cref="RouteExportService"/>.
|
/// там уже есть справочники сотрудников и логика описания шага, дублировать их
|
||||||
|
/// на бэкенде незачем. Здесь только вёрстка файла.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class RouteExportWorkbook
|
public static class RouteExportWorkbook
|
||||||
{
|
{
|
||||||
@@ -46,12 +37,12 @@ public static class RouteExportWorkbook
|
|||||||
(" ", 6.0),
|
(" ", 6.0),
|
||||||
("Роль ", 41.1),
|
("Роль ", 41.1),
|
||||||
("Исполнитель ", 42.2),
|
("Исполнитель ", 42.2),
|
||||||
("Организация ", 28.0),
|
|
||||||
("Условие ", 66.2),
|
("Условие ", 66.2),
|
||||||
("Комментарий ", 36.0),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
|
public const string FileName = "Маршруты согласования.xlsx";
|
||||||
|
|
||||||
|
public static byte[] Build(RouteExportRequest request)
|
||||||
{
|
{
|
||||||
var workbook = new XSSFWorkbook();
|
var workbook = new XSSFWorkbook();
|
||||||
|
|
||||||
@@ -60,7 +51,7 @@ public static class RouteExportWorkbook
|
|||||||
var titleStyle = CreateStyle(workbook, bold: true, border: false);
|
var titleStyle = CreateStyle(workbook, bold: true, border: false);
|
||||||
|
|
||||||
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var sheetData in sheets)
|
foreach (var sheetData in request.Sheets)
|
||||||
{
|
{
|
||||||
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
|
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
|
||||||
for (var i = 0; i < Columns.Length; i++)
|
for (var i = 0; i < Columns.Length; i++)
|
||||||
@@ -69,7 +60,7 @@ public static class RouteExportWorkbook
|
|||||||
var rowIndex = 0;
|
var rowIndex = 0;
|
||||||
foreach (var section in sheetData.Sections)
|
foreach (var section in sheetData.Sections)
|
||||||
{
|
{
|
||||||
if (rowIndex > 0) rowIndex++; // пустая строка только между маршрутами
|
if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(section.Title))
|
if (!string.IsNullOrWhiteSpace(section.Title))
|
||||||
{
|
{
|
||||||
@@ -81,20 +72,6 @@ public static class RouteExportWorkbook
|
|||||||
rowIndex++;
|
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++);
|
var header = sheet.CreateRow(rowIndex++);
|
||||||
for (var i = 0; i < Columns.Length; i++)
|
for (var i = 0; i < Columns.Length; i++)
|
||||||
{
|
{
|
||||||
@@ -102,11 +79,8 @@ public static class RouteExportWorkbook
|
|||||||
cell.SetCellValue(Columns[i].Title);
|
cell.SetCellValue(Columns[i].Title);
|
||||||
cell.CellStyle = headerStyle;
|
cell.CellStyle = headerStyle;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
firstGroup = false;
|
foreach (var row in section.Rows)
|
||||||
|
|
||||||
foreach (var row in group.Rows)
|
|
||||||
{
|
{
|
||||||
var excelRow = sheet.CreateRow(rowIndex++);
|
var excelRow = sheet.CreateRow(rowIndex++);
|
||||||
var numberCell = excelRow.CreateCell(0);
|
var numberCell = excelRow.CreateCell(0);
|
||||||
@@ -114,10 +88,7 @@ public static class RouteExportWorkbook
|
|||||||
numberCell.CellStyle = cellStyle;
|
numberCell.CellStyle = cellStyle;
|
||||||
WriteText(excelRow, 1, row.Role, cellStyle);
|
WriteText(excelRow, 1, row.Role, cellStyle);
|
||||||
WriteText(excelRow, 2, row.Executor, cellStyle);
|
WriteText(excelRow, 2, row.Executor, cellStyle);
|
||||||
WriteText(excelRow, 3, row.Organization, cellStyle);
|
WriteText(excelRow, 3, row.Condition, cellStyle);
|
||||||
WriteText(excelRow, 4, row.Condition, cellStyle);
|
|
||||||
WriteText(excelRow, 5, row.Comment, cellStyle);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-4
@@ -1,7 +1,6 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Tnsu.ApprovalRoute.Application.Common;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
@@ -83,7 +82,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
var entity = new TemplateRouteStep
|
var entity = new TemplateRouteStep
|
||||||
{
|
{
|
||||||
@@ -142,7 +141,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo;
|
entity.OrderNo = r.OrderNo;
|
||||||
entity.ExecutorEmail = r.ExecutorEmail;
|
entity.ExecutorEmail = r.ExecutorEmail;
|
||||||
@@ -211,7 +210,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||||
entity.ExecutorEmail = executorEmail;
|
entity.ExecutorEmail = executorEmail;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using FluentResults;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Tnsu.ApprovalRoute.Application.Common;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
@@ -438,7 +437,7 @@ public sealed class TemplateRouteService(
|
|||||||
{
|
{
|
||||||
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
|
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
|
||||||
}
|
}
|
||||||
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
||||||
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
||||||
@@ -470,14 +469,7 @@ public sealed class TemplateRouteService(
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
{
|
warning = $"Не найден руководитель типа «{step.HeadType}».";
|
||||||
var headLabel = StepLabels.InSentence(step.HeadType.Value);
|
|
||||||
// Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
|
|
||||||
// справочника сотрудников — там email не «не найден», а «не проставлен».
|
|
||||||
warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
|
|
||||||
? $"Не проставлен {headLabel}."
|
|
||||||
: $"Не найден {headLabel}.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -620,7 +612,7 @@ public sealed class TemplateRouteService(
|
|||||||
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
||||||
{
|
{
|
||||||
var conflictWarning =
|
var conflictWarning =
|
||||||
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}».";
|
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}.";
|
||||||
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
||||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||||
result[index] = existing with { Warning = combinedWarning };
|
result[index] = existing with { Warning = combinedWarning };
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ 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,27 +40,7 @@ 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, e.Position))
|
.Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty))
|
||||||
.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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,10 +121,6 @@ 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,12 +20,6 @@ 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,10 +30,4 @@ 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, string? Position = null);
|
public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company);
|
||||||
|
|
||||||
/// <summary>Компания из справочника /Dictionary/Companies.</summary>
|
|
||||||
public sealed record CompanyInfo(Guid Id, string Name);
|
|
||||||
|
|
||||||
/// <summary>Отдел из справочника /Dictionary/Departments.</summary>
|
|
||||||
public sealed record DepartmentInfo(Guid Id, string Name);
|
|
||||||
|
|||||||
+4
-5
@@ -361,9 +361,8 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
|
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldContain("Непосредственный руководитель");
|
warning.ShouldContain("Chief");
|
||||||
warning.ShouldContain("Руководитель направления");
|
warning.ShouldContain("DirectionHead");
|
||||||
warning.ShouldNotContain("DirectionHead");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
||||||
@@ -1051,7 +1050,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldBe("Не найден непосредственный руководитель.");
|
warning.ShouldContain("руководитель");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
||||||
@@ -1109,7 +1108,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldBe("В проекте не найден сотрудник службы безопасности.");
|
warning.ShouldContain("роль");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
||||||
|
|||||||
Generated
+4
-4
@@ -17,7 +17,7 @@
|
|||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@tnsu/ui-kit": "^0.14.0",
|
"@tnsu/ui-kit": "^0.4.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
@@ -6283,9 +6283,9 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/@tnsu/ui-kit": {
|
"node_modules/@tnsu/ui-kit": {
|
||||||
"version": "0.14.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.4.0/ui-kit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
"integrity": "sha512-J3cBvoR2vV0SC5GihOagLAqvC0txQE342qIh6viabMt+Um6Jg4mhTff+ZkVahG0g+bF6sqSCUl3x813aO82KuQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@tnsu/ui-kit": "^0.14.0",
|
"@tnsu/ui-kit": "^0.4.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -186,7 +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 === 'routesExport' && <RoutesExportScreen api={api} dictionary={dictionary} />}
|
||||||
{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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
-6
@@ -74,11 +74,6 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
|
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
|
||||||
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
|
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
|
||||||
|
|
||||||
const isDirty =
|
|
||||||
email !== initialEmail ||
|
|
||||||
slaHours !== slaHoursToInput(initialSlaHours) ||
|
|
||||||
askOverdueReason !== boolToTriState(initialAskOverdueReason);
|
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
const trimmedEmail = email.trim();
|
const trimmedEmail = email.trim();
|
||||||
if (!trimmedEmail) {
|
if (!trimmedEmail) {
|
||||||
@@ -100,7 +95,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose} isDirty={isDirty}>
|
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.manageRequests.approverLabel} required full>
|
<Field label={texts.manageRequests.approverLabel} required full>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
|
|||||||
-21
@@ -10,24 +10,3 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+18
-88
@@ -9,8 +9,6 @@ import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
|||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { useFormDraft } from '../../hooks/useFormDraft';
|
|
||||||
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
|
||||||
import { toEmployeeOption } from '../../utils/employee';
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
import { replaceRoute } from '../../routing';
|
import { replaceRoute } from '../../routing';
|
||||||
import { RequestTypeAccess } from '../../models';
|
import { RequestTypeAccess } from '../../models';
|
||||||
@@ -43,17 +41,6 @@ export interface IRequestTypeAccessScreenProps {
|
|||||||
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
|
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
|
||||||
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
|
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
|
||||||
|
|
||||||
/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */
|
|
||||||
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess';
|
|
||||||
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestTypeAccess';
|
|
||||||
|
|
||||||
interface IFormState {
|
|
||||||
requestTypeId: string;
|
|
||||||
emails: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_FORM: IFormState = { requestTypeId: '', emails: [] };
|
|
||||||
|
|
||||||
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
|
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
|
||||||
api,
|
api,
|
||||||
dictionary,
|
dictionary,
|
||||||
@@ -64,11 +51,9 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
const access = useRequestTypeAccess(api);
|
const access = useRequestTypeAccess(api);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
|
||||||
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
|
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||||
const { requestTypeId, emails } = draft.value;
|
const [email, setEmail] = React.useState('');
|
||||||
const setEmails = (update: (current: string[]) => string[]): void =>
|
|
||||||
draft.setValue(f => ({ ...f, emails: update(f.emails) }));
|
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
||||||
@@ -111,7 +96,9 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
|
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
createOpen.open();
|
setRequestTypeId('');
|
||||||
|
setEmail('');
|
||||||
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitForm = (): void => {
|
const submitForm = (): void => {
|
||||||
@@ -119,57 +106,17 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
submit.fail(texts.requestTypeAccess.validationRequestType);
|
submit.fail(texts.requestTypeAccess.validationRequestType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (emails.length === 0) {
|
if (!email.trim()) {
|
||||||
submit.fail(texts.requestTypeAccess.validationEmail);
|
submit.fail(texts.requestTypeAccess.validationEmail);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// По одному запросу на сотрудника (бэкенд выдаёт доступ по одному). Падение на
|
submit.run(() =>
|
||||||
// одном не отменяет остальных: неудавшиеся остаются в списке с причиной, чтобы
|
api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email: email.trim() }).then(() => {
|
||||||
// можно было поправить и нажать ещё раз.
|
setShowForm(false);
|
||||||
submit.run(async () => {
|
access.reload();
|
||||||
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) {
|
|
||||||
draft.reset();
|
|
||||||
createOpen.close();
|
|
||||||
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 (
|
||||||
@@ -242,15 +189,13 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{createOpen.isOpen && (
|
{showForm && (
|
||||||
<FormModal
|
<FormModal
|
||||||
title={texts.requestTypeAccess.createTitle}
|
title={texts.requestTypeAccess.createTitle}
|
||||||
error={submit.error}
|
error={submit.error}
|
||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={createOpen.close}
|
onClose={() => setShowForm(false)}
|
||||||
onReset={draft.reset}
|
|
||||||
canReset={draft.isDirty}
|
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
|
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
|
||||||
@@ -258,34 +203,19 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
|
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
|
||||||
value={requestTypeId}
|
value={requestTypeId}
|
||||||
onChange={e => draft.setValue(f => ({ ...f, requestTypeId: e.target.value }))}
|
onChange={e => setRequestTypeId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
||||||
<div>
|
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={employeeOptions}
|
options={employees.items.map(toEmployeeOption)}
|
||||||
value=""
|
value={email}
|
||||||
loading={employees.isLoading}
|
loading={employees.isLoading}
|
||||||
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
emptyMessage={texts.select.employeesEmpty}
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
onChange={value => value && setEmails(current => [...current, value])}
|
onChange={setEmail}
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
|||||||
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import { useFormDraft } from '../../hooks/useFormDraft';
|
|
||||||
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -59,22 +57,6 @@ const EMPTY_FORM: IFormState = {
|
|||||||
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
|
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
|
||||||
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType';
|
|
||||||
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestType';
|
|
||||||
|
|
||||||
function toFormState(item: RequestType): IFormState {
|
|
||||||
return {
|
|
||||||
name: item.name,
|
|
||||||
pageUrl: item.pageUrl,
|
|
||||||
section: item.section,
|
|
||||||
siteUrl: item.siteUrl ?? '',
|
|
||||||
listId: item.listId ?? '',
|
|
||||||
slaHours: slaHoursToInput(item.slaHours),
|
|
||||||
askOverdueReason: boolToTriState(item.askOverdueReason)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
||||||
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
||||||
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
||||||
@@ -96,18 +78,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
const list = useRequestTypes(api);
|
const list = useRequestTypes(api);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||||
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||||
const closeForm = (): void => {
|
|
||||||
setShowForm(false);
|
|
||||||
createOpen.close();
|
|
||||||
};
|
|
||||||
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
|
|
||||||
const [editForm, setEditForm] = React.useState<IFormState>(EMPTY_FORM);
|
|
||||||
const form = editing ? editForm : draft.value;
|
|
||||||
const isEditDirty = Boolean(editing) && JSON.stringify(editForm) !== JSON.stringify(toFormState(editing as RequestType));
|
|
||||||
const setForm = editing ? setEditForm : draft.setValue;
|
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
||||||
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
||||||
@@ -120,14 +92,21 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(false);
|
setForm(EMPTY_FORM);
|
||||||
createOpen.open();
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: RequestType): void => {
|
const openEdit = (item: RequestType): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setEditForm(toFormState(item));
|
setForm({
|
||||||
createOpen.close();
|
name: item.name,
|
||||||
|
pageUrl: item.pageUrl,
|
||||||
|
section: item.section,
|
||||||
|
siteUrl: item.siteUrl ?? '',
|
||||||
|
listId: item.listId ?? '',
|
||||||
|
slaHours: slaHoursToInput(item.slaHours),
|
||||||
|
askOverdueReason: boolToTriState(item.askOverdueReason)
|
||||||
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,8 +143,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(() => {
|
request.then(() => {
|
||||||
if (!editing) draft.reset();
|
setShowForm(false);
|
||||||
closeForm();
|
|
||||||
list.reload();
|
list.reload();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -214,16 +192,13 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(showForm || createOpen.isOpen) && (
|
{showForm && (
|
||||||
<FormModal
|
<FormModal
|
||||||
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
||||||
error={submit.error}
|
error={submit.error}
|
||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={closeForm}
|
onClose={() => setShowForm(false)}
|
||||||
onReset={editing ? undefined : draft.reset}
|
|
||||||
canReset={draft.isDirty}
|
|
||||||
isDirty={isEditDirty}
|
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.requestTypes.nameLabel} required full>
|
<Field label={texts.requestTypes.nameLabel} required full>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as React from 'react';
|
|||||||
import { Company, Department, RequestType, TemplateRoute } from '../../models';
|
import { Company, Department, RequestType, TemplateRoute } from '../../models';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useFormDraft } from '../../hooks/useFormDraft';
|
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
||||||
|
|
||||||
@@ -28,9 +27,6 @@ interface IFormState {
|
|||||||
requiresControl: boolean;
|
requiresControl: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
|
||||||
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.route';
|
|
||||||
|
|
||||||
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
return {
|
return {
|
||||||
@@ -62,10 +58,7 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
onSaved,
|
onSaved,
|
||||||
onClose
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
// Редактирование не персистим — форма всегда открывается с данными маршрута с сервера.
|
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId));
|
||||||
const draft = useFormDraft<IFormState>(editing ? undefined : CREATE_DRAFT_STORAGE_KEY, toFormState(editing, initialRequestTypeId));
|
|
||||||
const form = draft.value;
|
|
||||||
const setForm = draft.setValue;
|
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
||||||
@@ -100,7 +93,6 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(() => {
|
request.then(() => {
|
||||||
draft.reset();
|
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
@@ -114,9 +106,6 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onReset={editing ? undefined : draft.reset}
|
|
||||||
canReset={draft.isDirty}
|
|
||||||
isDirty={Boolean(editing) && draft.isDirty}
|
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.routes.requestTypeLabel} required full>
|
<Field label={texts.routes.requestTypeLabel} required full>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useFormDraft } from '../../hooks/useFormDraft';
|
|
||||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||||||
@@ -120,9 +119,6 @@ const IS_MANAGER_VALUE_OPTIONS = [
|
|||||||
{ value: 'false', label: texts.common.no }
|
{ value: 'false', label: texts.common.no }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Черновик формы создания шага — свой у каждого маршрута, переживает закрытие модалки (см. useFormDraft). */
|
|
||||||
const createDraftStorageKey = (routeId: number): string => `approvalAdmin.draft.routeStep.${routeId}`;
|
|
||||||
|
|
||||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
return {
|
return {
|
||||||
@@ -174,10 +170,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
onSaved,
|
onSaved,
|
||||||
onClose
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
// Редактирование не персистим — форма всегда открывается с данными шага с сервера.
|
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||||
const draft = useFormDraft<IFormState>(editing ? undefined : createDraftStorageKey(route.id), toFormState(editing, nextOrderNo));
|
|
||||||
const form = draft.value;
|
|
||||||
const setForm = draft.setValue;
|
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
const hasListId = Boolean(requestType?.listId);
|
const hasListId = Boolean(requestType?.listId);
|
||||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||||
@@ -407,7 +400,6 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(step => {
|
request.then(step => {
|
||||||
draft.reset();
|
|
||||||
onSaved(step);
|
onSaved(step);
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
@@ -421,9 +413,6 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onReset={editing ? undefined : draft.reset}
|
|
||||||
canReset={draft.isDirty}
|
|
||||||
isDirty={Boolean(editing) && draft.isDirty}
|
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.routeSteps.orderNoLabel} required>
|
<Field label={texts.routeSteps.orderNoLabel} required>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u
|
|||||||
import { reportError } from '../../utils/errors';
|
import { reportError } from '../../utils/errors';
|
||||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import {
|
import {
|
||||||
@@ -54,14 +53,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||||
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen),
|
|
||||||
// ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal).
|
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const createOpen = usePersistedOpen(`approvalAdmin.draftOpen.routeStep.${route.id}`);
|
|
||||||
const closeForm = (): void => {
|
|
||||||
setShowForm(false);
|
|
||||||
createOpen.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
||||||
item => api.templateRouteSteps.remove(route.id, item.id),
|
item => api.templateRouteSteps.remove(route.id, item.id),
|
||||||
@@ -79,13 +71,11 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(false);
|
setShowForm(true);
|
||||||
createOpen.open();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRouteStep): void => {
|
const openEdit = (item: TemplateRouteStep): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
createOpen.close();
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,7 +278,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(showForm || createOpen.isOpen) && (
|
{showForm && (
|
||||||
<RouteStepFormModal
|
<RouteStepFormModal
|
||||||
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
||||||
// применяется к любой компании инициатора) — тогда подсказка
|
// применяется к любой компании инициатора) — тогда подсказка
|
||||||
@@ -303,7 +293,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
employees={employees.items}
|
employees={employees.items}
|
||||||
employeesLoading={employees.isLoading}
|
employeesLoading={employees.isLoading}
|
||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
onClose={closeForm}
|
onClose={() => setShowForm(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import { reportError } from '../../utils/errors';
|
import { reportError } from '../../utils/errors';
|
||||||
|
import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport';
|
||||||
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
|
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
|
||||||
|
|
||||||
export interface IRoutesExportScreenProps {
|
export interface IRoutesExportScreenProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
|
|
||||||
// делают по одному и тому же набору типов.
|
|
||||||
const SELECTED_STORAGE_KEY = 'approvalAdmin.routesExport.selected';
|
|
||||||
|
|
||||||
function download(blob: Blob, fileName: string): void {
|
function download(blob: Blob, fileName: string): void {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -25,30 +26,45 @@ function download(blob: Blob, fileName: string): void {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
|
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, dictionary }) => {
|
||||||
const requestTypes = useRequestTypes(api);
|
const requestTypes = useRequestTypes(api);
|
||||||
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||||
|
|
||||||
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
|
const [selected, setSelected] = React.useState<number[]>([]);
|
||||||
const [isBusy, setIsBusy] = React.useState(false);
|
const [isBusy, setIsBusy] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | undefined>();
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
const allIds = requestTypes.items.map(rt => rt.id);
|
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 allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1);
|
||||||
|
|
||||||
const toggle = (id: number, checked: boolean): void =>
|
const toggle = (id: number, checked: boolean): void =>
|
||||||
setSelected(current => (checked ? [...current.filter(x => x !== id), id] : current.filter(x => x !== id)));
|
setSelected(current => (checked ? [...current, id] : current.filter(x => x !== id)));
|
||||||
|
|
||||||
const runExport = (): void => {
|
const runExport = (): void => {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
api.reports.routes(selected)
|
// Порядок листов — как в списке типов, а не в порядке кликов.
|
||||||
.then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName))
|
const types = requestTypes.items.filter(rt => selected.indexOf(rt.id) !== -1);
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
types.map(async rt => {
|
||||||
|
const routes = await api.templateRoutes.list(rt.id);
|
||||||
|
const items: RouteWithSteps[] = await Promise.all(
|
||||||
|
routes.map(async route => ({ route, steps: await api.templateRouteSteps.list(route.id) }))
|
||||||
|
);
|
||||||
|
return buildRouteSheet(rt, items, employees.items, portalDictionaries.companies, portalDictionaries.departments);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then(sheets => api.reports.routes({ sheets }))
|
||||||
|
.then(blob => download(blob, texts.routesExport.fileName))
|
||||||
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
|
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
|
||||||
.then(() => setIsBusy(false), () => setIsBusy(false));
|
.then(() => setIsBusy(false), () => setIsBusy(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться.
|
||||||
|
const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
|
||||||
@@ -60,7 +76,7 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api })
|
|||||||
<Button
|
<Button
|
||||||
variant="accent"
|
variant="accent"
|
||||||
icon="check"
|
icon="check"
|
||||||
disabled={selected.length === 0 || isBusy}
|
disabled={selected.length === 0 || isBusy || dictionariesLoading}
|
||||||
onClick={runExport}
|
onClick={runExport}
|
||||||
>
|
>
|
||||||
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
|
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
|
||||||
@@ -69,6 +85,7 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api })
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||||
|
{employees.error && <Alert kind="err">{employees.error}</Alert>}
|
||||||
{error && <Alert kind="err">{error}</Alert>}
|
{error && <Alert kind="err">{error}</Alert>}
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
|||||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { replaceRoute } from '../../routing';
|
import { replaceRoute } from '../../routing';
|
||||||
import {
|
import {
|
||||||
@@ -40,8 +39,6 @@ export interface IRoutesScreenProps {
|
|||||||
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
||||||
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
||||||
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
||||||
/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */
|
|
||||||
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route';
|
|
||||||
|
|
||||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||||
api,
|
api,
|
||||||
@@ -72,13 +69,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||||
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
|
||||||
const closeForm = (): void => {
|
|
||||||
setShowForm(false);
|
|
||||||
createOpen.close();
|
|
||||||
};
|
|
||||||
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
||||||
|
|
||||||
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||||
@@ -92,13 +83,11 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(false);
|
setShowForm(true);
|
||||||
createOpen.open();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRoute): void => {
|
const openEdit = (item: TemplateRoute): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
createOpen.close();
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,7 +159,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(showForm || createOpen.isOpen) && (
|
{showForm && (
|
||||||
<RouteFormModal
|
<RouteFormModal
|
||||||
api={api}
|
api={api}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@@ -180,7 +169,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
departments={portalDictionaries.departments}
|
departments={portalDictionaries.departments}
|
||||||
dictionaryLoading={portalDictionaries.isLoading}
|
dictionaryLoading={portalDictionaries.isLoading}
|
||||||
onSaved={routes.reload}
|
onSaved={routes.reload}
|
||||||
onClose={closeForm}
|
onClose={() => setShowForm(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,12 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
box-shadow: $sh-1;
|
box-shadow: $sh-1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@include card-hover-gold;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
// Миксин с вложенным &:hover — после всех объявлений, иначе Sass ругается mixed-decls.
|
|
||||||
@include card-hover-gold;
|
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Modal.module.scss';
|
import styles from './Modal.module.scss';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { ModalOverlay } from './ModalOverlay';
|
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IConfirmDialogProps {
|
export interface IConfirmDialogProps {
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
/** Подпись кнопки отказа — по умолчанию «Отмена». */
|
|
||||||
cancelLabel?: string;
|
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
@@ -19,19 +16,18 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
|
|||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
cancelLabel = texts.common.cancel,
|
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy
|
busy
|
||||||
}) => (
|
}) => (
|
||||||
<ModalOverlay onBackdropClick={onCancel}>
|
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
||||||
<div className={`${styles.box} ${styles.narrow}`}>
|
<div className={`${styles.box} ${styles.narrow}`}>
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.title}>{title}</div>
|
||||||
<p className={styles.text}>{text}</p>
|
<p className={styles.text}>{text}</p>
|
||||||
<div className={styles.narrowFooter}>
|
<div className={styles.narrowFooter}>
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
<Button variant="secondary" onClick={onCancel} disabled={busy}>{texts.common.cancel}</Button>
|
||||||
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalOverlay>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert } from './Alert';
|
import { Alert } from './Alert';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { ConfirmDialog } from './ConfirmDialog';
|
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
import styles from './Modal.module.scss';
|
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IFormModalProps {
|
export interface IFormModalProps {
|
||||||
@@ -17,18 +15,6 @@ export interface IFormModalProps {
|
|||||||
savingLabel?: string;
|
savingLabel?: string;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/**
|
|
||||||
* Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается,
|
|
||||||
* только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают.
|
|
||||||
*/
|
|
||||||
onReset?: () => void;
|
|
||||||
canReset?: boolean;
|
|
||||||
/**
|
|
||||||
* В форме есть несохранённые правки существующей записи: выход (крестик,
|
|
||||||
* «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания
|
|
||||||
* не нужен — их черновик сохраняется сам (см. useFormDraft).
|
|
||||||
*/
|
|
||||||
isDirty?: boolean;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,32 +30,14 @@ export const FormModal: React.FC<IFormModalProps> = ({
|
|||||||
savingLabel = texts.common.saving,
|
savingLabel = texts.common.saving,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
onClose,
|
||||||
onReset,
|
|
||||||
canReset,
|
|
||||||
isDirty,
|
|
||||||
children
|
children
|
||||||
}) => {
|
}) => (
|
||||||
const [confirmingClose, setConfirmingClose] = React.useState(false);
|
|
||||||
// Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл.
|
|
||||||
const requestClose = (): void => {
|
|
||||||
if (isSaving) return;
|
|
||||||
if (isDirty) setConfirmingClose(true);
|
|
||||||
else onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
<Modal
|
||||||
title={title}
|
title={title}
|
||||||
onClose={requestClose}
|
onClose={onClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
{onReset && canReset && (
|
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
|
||||||
<span className={styles.footerStart}>
|
|
||||||
<Button variant="ghost" icon="refresh" onClick={onReset} disabled={isSaving}>{texts.common.resetDraft}</Button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Button variant="secondary" onClick={requestClose} disabled={isSaving}>{texts.common.cancel}</Button>
|
|
||||||
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
||||||
{isSaving ? savingLabel : submitLabel}
|
{isSaving ? savingLabel : submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -79,16 +47,4 @@ export const FormModal: React.FC<IFormModalProps> = ({
|
|||||||
{error && <Alert kind="err">{error}</Alert>}
|
{error && <Alert kind="err">{error}</Alert>}
|
||||||
{children}
|
{children}
|
||||||
</Modal>
|
</Modal>
|
||||||
{confirmingClose && (
|
|
||||||
<ConfirmDialog
|
|
||||||
title={texts.common.discardTitle}
|
|
||||||
text={texts.common.discardText}
|
|
||||||
confirmLabel={texts.common.discardConfirm}
|
|
||||||
cancelLabel={texts.common.discardKeepEditing}
|
|
||||||
onConfirm={onClose}
|
|
||||||
onCancel={() => setConfirmingClose(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|||||||
@@ -7,17 +7,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
// Выше шапки SharePoint, но ниже слоёв Fluent UI (1000000). Оверлей рендерится
|
z-index: 300;
|
||||||
// порталом в body (см. ModalOverlay), поэтому сравнивается с шапкой напрямую.
|
|
||||||
z-index: 100000;
|
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
// Портал вне корня веб-части — шрифт и метрики .shell (AdminApp.module.scss)
|
|
||||||
// сюда не наследуются, задаём заново.
|
|
||||||
font-family: $font-sans;
|
|
||||||
font-size: $fs-body;
|
|
||||||
line-height: 1.55;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
@@ -71,7 +63,4 @@
|
|||||||
padding: 16px 26px 22px;
|
padding: 16px 26px 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Кнопка в подвале, прижатая влево, подальше от «Отмена/Сохранить» (см. «Очистить» в FormModal). */
|
|
||||||
.footerStart { margin-right: auto; }
|
|
||||||
|
|
||||||
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Modal.module.scss';
|
import styles from './Modal.module.scss';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
import { ModalOverlay } from './ModalOverlay';
|
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IModalProps {
|
export interface IModalProps {
|
||||||
@@ -12,7 +11,8 @@ export interface IModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
||||||
<ModalOverlay onBackdropClick={onClose}>
|
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
|
||||||
|
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div className={styles.box}>
|
<div className={styles.box}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.title}>{title}</div>
|
||||||
@@ -21,5 +21,5 @@ export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children
|
|||||||
<div className={styles.body}>{children}</div>
|
<div className={styles.body}>{children}</div>
|
||||||
{footer && <div className={styles.footer}>{footer}</div>}
|
{footer && <div className={styles.footer}>{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</ModalOverlay>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import * as ReactDOM from 'react-dom';
|
|
||||||
import styles from './Modal.module.scss';
|
|
||||||
|
|
||||||
export interface IModalOverlayProps {
|
|
||||||
/** Клик по подложке (не по содержимому окна). */
|
|
||||||
onBackdropClick: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Затемнение под модалкой — порталом в document.body, а не внутри веб-части.
|
|
||||||
* На живой странице SharePoint канва веб-части лежит в своём контексте наложения
|
|
||||||
* ниже шапки (suite bar), и position: fixed-оверлей изнутри неё оказывался ПОД
|
|
||||||
* шапкой: верх окна, центрированного по высоте экрана, уезжал за неё. В workbench
|
|
||||||
* шапки нет — там было незаметно.
|
|
||||||
*/
|
|
||||||
export const ModalOverlay: React.FC<IModalOverlayProps> = ({ onBackdropClick, children }) =>
|
|
||||||
ReactDOM.createPortal(
|
|
||||||
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
|
|
||||||
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onBackdropClick(); }}>
|
|
||||||
{children}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
// Ниже Modal.module.scss/.overlay (z-index 100000) — диалоги, которые может
|
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может
|
||||||
// открыть сама длительная операция (например, разрешение конфликта), должны
|
// открыть сама длительная операция (например, разрешение конфликта), должны
|
||||||
// быть ПОВЕРХ этой подложки, а не под ней.
|
// быть ПОВЕРХ этой подложки, а не под ней.
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
box-shadow: $sh-modal;
|
box-shadow: $sh-modal;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
z-index: 100001; // над оверлеем модалки (Modal.module.scss)
|
z-index: 400;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -116,7 +116,6 @@
|
|||||||
|
|
||||||
&: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,8 +9,6 @@ export interface ISearchableOption {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Второстепенная строка под названием — например, компания у отдела. */
|
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISearchableSelectProps {
|
export interface ISearchableSelectProps {
|
||||||
@@ -137,7 +135,6 @@ 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}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export * from './PrimaryCell';
|
|||||||
export * from './ActionsCell';
|
export * from './ActionsCell';
|
||||||
export * from './CellNote';
|
export * from './CellNote';
|
||||||
export * from './Modal';
|
export * from './Modal';
|
||||||
export * from './ModalOverlay';
|
|
||||||
export * from './ConfirmDialog';
|
export * from './ConfirmDialog';
|
||||||
export * from './FormModal';
|
export * from './FormModal';
|
||||||
export * from './FormGrid';
|
export * from './FormGrid';
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { readLocalStorage } from './useLocalStorageState';
|
|
||||||
|
|
||||||
export interface IFormDraft<T> {
|
|
||||||
value: T;
|
|
||||||
setValue: React.Dispatch<React.SetStateAction<T>>;
|
|
||||||
/** Форма отличается от initial — есть что сбрасывать. */
|
|
||||||
isDirty: boolean;
|
|
||||||
/** Вернуть initial и забыть черновик (кнопка «Очистить», успешное сохранение). */
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeDraft(key: string): void {
|
|
||||||
try {
|
|
||||||
window.localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
// приватный режим — черновика и не было
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Состояние формы создания, которое переживает закрытие модалки (случайный клик
|
|
||||||
* по подложке, «Отмена», F5): черновик лежит в localStorage под key, пока его
|
|
||||||
* не сбросят явно — reset() после успешного сохранения или по кнопке «Очистить».
|
|
||||||
*
|
|
||||||
* key === undefined — обычный useState без персиста: так форма редактирования
|
|
||||||
* существующей записи всегда открывается с актуальными данными с сервера, а не
|
|
||||||
* с устаревшей правкой.
|
|
||||||
*/
|
|
||||||
export function useFormDraft<T>(key: string | undefined, initial: T): IFormDraft<T> {
|
|
||||||
const [value, setValue] = React.useState<T>(() => (key ? readLocalStorage(key, initial) : initial));
|
|
||||||
|
|
||||||
// initial у вызывающих — литерал, пересоздаётся каждый рендер; сравниваем по содержимому.
|
|
||||||
const initialJson = JSON.stringify(initial);
|
|
||||||
const isDirty = JSON.stringify(value) !== initialJson;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!key) return;
|
|
||||||
if (!isDirty) {
|
|
||||||
removeDraft(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
} catch {
|
|
||||||
// приватный режим / переполненная квота — не критично, просто не персистим
|
|
||||||
}
|
|
||||||
}, [key, value, isDirty]);
|
|
||||||
|
|
||||||
// Ключ удаляем синхронно, а не через эффект: после успешного сохранения модалка
|
|
||||||
// тут же размонтируется, и эффект на новое значение уже не отработает.
|
|
||||||
const reset = (): void => {
|
|
||||||
if (key) removeDraft(key);
|
|
||||||
setValue(JSON.parse(initialJson) as T);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { value, setValue, isDirty, reset };
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { coerceStored } from './useLocalStorageState';
|
|
||||||
|
|
||||||
describe('coerceStored', () => {
|
|
||||||
const initial = { name: '', count: 0, flag: false, tags: [] as string[], nested: { a: '' } };
|
|
||||||
|
|
||||||
it('подставляет дефолт для полей, добавленных после сохранения', () => {
|
|
||||||
expect(coerceStored(initial, { name: 'x' })).toEqual({ ...initial, name: 'x' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('отбрасывает ключи, которых в форме больше нет', () => {
|
|
||||||
expect(coerceStored(initial, { name: 'x', removed: 1 })).not.toHaveProperty('removed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('заменяет значение другого типа дефолтом', () => {
|
|
||||||
expect(coerceStored(initial, { name: 5, tags: { a: 1 }, flag: 'yes', nested: 'oops' }))
|
|
||||||
.toEqual(initial);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('принимает массив нужного типа и мёржит вложенный объект', () => {
|
|
||||||
expect(coerceStored(initial, { tags: ['a'], nested: {} }))
|
|
||||||
.toEqual({ ...initial, tags: ['a'] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('не падает на null и примитиве вместо объекта', () => {
|
|
||||||
expect(coerceStored(initial, null)).toEqual(initial);
|
|
||||||
expect(coerceStored(initial, 'x')).toEqual(initial);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('для примитивного initial проверяет тип', () => {
|
|
||||||
expect(coerceStored('', 5)).toBe('');
|
|
||||||
expect(coerceStored('', 'abc')).toBe('abc');
|
|
||||||
expect(coerceStored<number[]>([], [1, 2])).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('для boolean-флага отбрасывает значение другого типа', () => {
|
|
||||||
expect(coerceStored(false, true)).toBe(true);
|
|
||||||
expect(coerceStored(false, 'true')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('при initial === undefined принимает сохранённое как есть', () => {
|
|
||||||
expect(coerceStored<string | undefined>(undefined, 'a')).toBe('a');
|
|
||||||
expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,56 +4,28 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Сохранённое значение, приведённое к форме initial. Запись могла быть сделана
|
|
||||||
* старой версией формы, поэтому ей нельзя доверять слепо:
|
|
||||||
* - объект мёржится с initial — добавленные с тех пор поля получают дефолт;
|
|
||||||
* - ключи, которых в initial нет (поле удалили), отбрасываются;
|
|
||||||
* - значение, чей тип не совпал с initial (было число — стала строка, было
|
|
||||||
* [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер;
|
|
||||||
* - null/undefined в initial типа не задают — там принимаем любое сохранённое.
|
|
||||||
*/
|
|
||||||
export function coerceStored<T>(initial: T, stored: unknown): T {
|
|
||||||
if (initial === null || initial === undefined) return stored as T;
|
|
||||||
if (Array.isArray(initial)) return (Array.isArray(stored) ? stored : initial) as unknown as T;
|
|
||||||
if (isPlainObject(initial)) {
|
|
||||||
if (!isPlainObject(stored)) return initial;
|
|
||||||
const result: Record<string, unknown> = { ...initial };
|
|
||||||
Object.keys(initial).forEach(k => {
|
|
||||||
if (k in stored) result[k] = coerceStored(initial[k], stored[k]);
|
|
||||||
});
|
|
||||||
return result as T;
|
|
||||||
}
|
|
||||||
return (typeof stored === typeof initial ? stored : initial) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Значение из localStorage под key, приведённое к форме initial (см. coerceStored),
|
|
||||||
* или initial, если записи нет / она битая / localStorage недоступен.
|
|
||||||
*/
|
|
||||||
export function readLocalStorage<T>(key: string, initial: T): T {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(key);
|
|
||||||
if (!raw) return initial;
|
|
||||||
|
|
||||||
return coerceStored(initial, JSON.parse(raw));
|
|
||||||
} catch {
|
|
||||||
return initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
||||||
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||||
* квота) не ломает форму — просто не персистит.
|
* квота) не ломает форму — просто не персистит.
|
||||||
*
|
*
|
||||||
* Сохранённое значение приводится к форме initial (см. coerceStored): новое поле
|
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
|
||||||
* получает дефолт, удалённое отбрасывается, значение не того типа заменяется
|
* обновления в форму добавили новое поле, у пользователей со старой записью в
|
||||||
* дефолтом — иначе старая запись в localStorage уронила бы рендер на первом же
|
* localStorage оно не потеряется как undefined (что уронило бы рендер на
|
||||||
* .map/.trim по этому полю.
|
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
|
||||||
*/
|
*/
|
||||||
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||||
const [value, setValue] = React.useState<T>(() => readLocalStorage(key, initial));
|
const [value, setValue] = React.useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (!raw) return initial;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as T;
|
||||||
|
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
|
||||||
|
} catch {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useLocalStorageState } from './useLocalStorageState';
|
|
||||||
|
|
||||||
export interface IPersistedOpen {
|
|
||||||
isOpen: boolean;
|
|
||||||
open: () => void;
|
|
||||||
close: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Признак «модалка создания открыта», переживающий F5: после перезагрузки окно
|
|
||||||
* открывается снова, а поля подставляются из черновика (см. useFormDraft).
|
|
||||||
* close() сбрасывает только признак — черновик остаётся до сохранения или «Очистить».
|
|
||||||
*/
|
|
||||||
export function usePersistedOpen(key: string): IPersistedOpen {
|
|
||||||
const [isOpen, setIsOpen] = useLocalStorageState<boolean>(key, false);
|
|
||||||
return { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false) };
|
|
||||||
}
|
|
||||||
@@ -9,3 +9,4 @@ export * from './templateRouteSteps';
|
|||||||
export * from './resolve';
|
export * from './resolve';
|
||||||
export * from './requestManage';
|
export * from './requestManage';
|
||||||
export * from './manageByUser';
|
export * from './manageByUser';
|
||||||
|
export * from './routeExport';
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// DTO эндпоинта POST api/reports/routes (Application/Reports/RouteExportWorkbook.cs).
|
||||||
|
|
||||||
|
export interface RouteExportRow {
|
||||||
|
number: number;
|
||||||
|
role: string;
|
||||||
|
executor: string;
|
||||||
|
condition: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteExportSection {
|
||||||
|
/** Заголовок маршрута — только когда у типа заявки их несколько. */
|
||||||
|
title?: string;
|
||||||
|
rows: RouteExportRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лист файла — один тип заявки. */
|
||||||
|
export interface RouteExportSheet {
|
||||||
|
name: string;
|
||||||
|
sections: RouteExportSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteExportRequest {
|
||||||
|
sheets: RouteExportSheet[];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IDownloadedFile, IHttpClient } from './httpClient';
|
import { IHttpClient } from './httpClient';
|
||||||
import {
|
import {
|
||||||
CreateRequestTypeAccessInput,
|
CreateRequestTypeAccessInput,
|
||||||
CreateRequestTypeInput,
|
CreateRequestTypeInput,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
PatchTemplateRouteInput,
|
PatchTemplateRouteInput,
|
||||||
PatchTemplateRouteStepInput,
|
PatchTemplateRouteStepInput,
|
||||||
RequestListPartition,
|
RequestListPartition,
|
||||||
|
RouteExportRequest,
|
||||||
RequestType,
|
RequestType,
|
||||||
RequestTypeAccess,
|
RequestTypeAccess,
|
||||||
RequestTypeListSeries,
|
RequestTypeListSeries,
|
||||||
@@ -175,7 +176,7 @@ export class ApprovalRouteApi {
|
|||||||
|
|
||||||
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
|
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
|
||||||
public readonly reports = {
|
public readonly reports = {
|
||||||
routes: (requestTypeIds: number[]): Promise<IDownloadedFile> =>
|
routes: (body: RouteExportRequest): Promise<Blob> =>
|
||||||
this.http.postForBlob('/api/reports/routes', { requestTypeIds })
|
this.http.postForBlob('/api/reports/routes', body)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,28 +18,6 @@ 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 {
|
||||||
@@ -49,7 +27,7 @@ export interface IHttpClient {
|
|||||||
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-конвертом. */
|
/** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */
|
||||||
postForBlob(path: string, body: unknown): Promise<IDownloadedFile>;
|
postForBlob(path: string, body: unknown): Promise<Blob>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
||||||
@@ -129,7 +107,7 @@ 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> {
|
public async postForBlob(path: string, body: unknown): Promise<Blob> {
|
||||||
const url = buildUrl(this.baseUrl, path);
|
const url = buildUrl(this.baseUrl, path);
|
||||||
const response = await this.client.fetch(url, AadHttpClient.configurations.v1, {
|
const response = await this.client.fetch(url, AadHttpClient.configurations.v1, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -141,7 +119,7 @@ export class AadApiHttpClient implements IHttpClient {
|
|||||||
await parseResponse<void>(response);
|
await parseResponse<void>(response);
|
||||||
throw new ApiClientError(response.status, [`HTTP ${response.status}`]);
|
throw new ApiClientError(response.status, [`HTTP ${response.status}`]);
|
||||||
}
|
}
|
||||||
return { blob: await response.blob(), fileName: fileNameFromDisposition(response.headers.get('Content-Disposition')) };
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
|||||||
@@ -76,13 +76,6 @@ export const texts = {
|
|||||||
remove: 'Удалить',
|
remove: 'Удалить',
|
||||||
edit: 'Редактировать',
|
edit: 'Редактировать',
|
||||||
close: 'Закрыть',
|
close: 'Закрыть',
|
||||||
/** Сброс черновика формы создания — сам черновик переживает закрытие окна. */
|
|
||||||
resetDraft: 'Очистить',
|
|
||||||
/** Предупреждение при выходе из формы редактирования с несохранёнными правками. */
|
|
||||||
discardTitle: 'Закрыть без сохранения?',
|
|
||||||
discardText: 'Внесённые изменения не сохранятся.',
|
|
||||||
discardConfirm: 'Закрыть без сохранения',
|
|
||||||
discardKeepEditing: 'Продолжить редактирование',
|
|
||||||
loading: 'Загрузка...',
|
loading: 'Загрузка...',
|
||||||
yes: 'Да',
|
yes: 'Да',
|
||||||
no: 'Нет',
|
no: 'Нет',
|
||||||
@@ -205,16 +198,10 @@ 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}».`
|
||||||
@@ -224,12 +211,16 @@ export const texts = {
|
|||||||
breadcrumb: 'Выгрузка маршрутов',
|
breadcrumb: 'Выгрузка маршрутов',
|
||||||
subtitle: 'Маршруты согласования в Excel',
|
subtitle: 'Маршруты согласования в Excel',
|
||||||
title: 'Выгрузка маршрутов',
|
title: 'Выгрузка маршрутов',
|
||||||
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (согласующие и исполнители отдельными таблицами).',
|
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (роль, исполнитель, условие).',
|
||||||
selectAll: 'Выбрать все',
|
selectAll: 'Выбрать все',
|
||||||
export: 'Выгрузить',
|
export: 'Выгрузить',
|
||||||
exporting: 'Формирование файла...',
|
exporting: 'Формирование файла...',
|
||||||
emptyTypes: 'Типов заявок пока нет.',
|
emptyTypes: 'Типов заявок пока нет.',
|
||||||
selected: (count: number): string => `Выбрано: ${count}`,
|
selected: (count: number): string => `Выбрано: ${count}`,
|
||||||
|
routeTitle: (company: string, department: string, byProject: boolean): string =>
|
||||||
|
`Маршрут: ${company} · ${department}${byProject ? ' · по проекту' : ''}`,
|
||||||
|
always: 'Всегда',
|
||||||
|
executorByRequest: 'Определяется по заявке',
|
||||||
fileName: 'Маршруты согласования.xlsx',
|
fileName: 'Маршруты согласования.xlsx',
|
||||||
failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}`
|
failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}`
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Company, Department, Employee, HeadType, ProjectRole, RequestType, RouteExportRow, RouteExportSheet, TemplateRoute, TemplateRouteStep } from '../models';
|
||||||
|
import { headTypeLabels, projectRoleLabels, texts } from '../texts';
|
||||||
|
import { describeStepCondition } from './stepExecutor';
|
||||||
|
import { findEmployeeByEmail } from './employee';
|
||||||
|
import { shortGuid } from './guid';
|
||||||
|
|
||||||
|
export interface RouteWithSteps {
|
||||||
|
route: TemplateRoute;
|
||||||
|
steps: TemplateRouteStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Роль и исполнитель шага для выгрузки — как в ручной таблице HR: «роль» это
|
||||||
|
* должность/тип руководителя, «исполнитель» — конкретный человек, если он
|
||||||
|
* известен уже на этапе настройки маршрута.
|
||||||
|
*/
|
||||||
|
function describeRoleAndExecutor(step: TemplateRouteStep, employees: Employee[]): { role: string; executor: string } {
|
||||||
|
if (step.executorEmail) {
|
||||||
|
const employee = findEmployeeByEmail(employees, step.executorEmail);
|
||||||
|
return { role: employee?.position || texts.common.empty, executor: employee?.fio ?? step.executorEmail };
|
||||||
|
}
|
||||||
|
if (step.position) {
|
||||||
|
const wanted = step.position.trim().toLowerCase();
|
||||||
|
const holder = employees.find(e => e.position.trim().toLowerCase() === wanted);
|
||||||
|
return { role: step.position, executor: holder?.fio ?? texts.common.empty };
|
||||||
|
}
|
||||||
|
if (step.projectRole && step.projectRole !== ProjectRole.None) {
|
||||||
|
return { role: projectRoleLabels[step.projectRole], executor: texts.routesExport.executorByRequest };
|
||||||
|
}
|
||||||
|
if (step.headType && step.headType !== HeadType.None) {
|
||||||
|
return { role: headTypeLabels[step.headType], executor: texts.routesExport.executorByRequest };
|
||||||
|
}
|
||||||
|
return { role: texts.common.empty, executor: texts.common.empty };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строки листа: параллельные шаги (равный orderNo) получают один и тот же номер. */
|
||||||
|
export function buildRouteRows(steps: TemplateRouteStep[], employees: Employee[]): RouteExportRow[] {
|
||||||
|
const orderNos = Array.from(new Set(steps.map(s => s.orderNo))).sort((a, b) => a - b);
|
||||||
|
return [...steps]
|
||||||
|
.sort((a, b) => a.orderNo - b.orderNo)
|
||||||
|
.map(step => ({
|
||||||
|
number: orderNos.indexOf(step.orderNo) + 1,
|
||||||
|
...describeRoleAndExecutor(step, employees),
|
||||||
|
condition: describeStepCondition(step) ?? texts.routesExport.always
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лист на тип заявки. Заголовки маршрутов нужны, только когда их у типа несколько. */
|
||||||
|
export function buildRouteSheet(
|
||||||
|
requestType: RequestType,
|
||||||
|
items: RouteWithSteps[],
|
||||||
|
employees: Employee[],
|
||||||
|
companies: Company[],
|
||||||
|
departments: Department[]
|
||||||
|
): RouteExportSheet {
|
||||||
|
const title = (route: TemplateRoute): string | undefined => {
|
||||||
|
if (items.length < 2) return undefined;
|
||||||
|
const company = route.companyId
|
||||||
|
? companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId)
|
||||||
|
: texts.routeSteps.anyCompany;
|
||||||
|
const department = route.departmentId
|
||||||
|
? departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId)
|
||||||
|
: texts.routeSteps.anyDepartment;
|
||||||
|
return texts.routesExport.routeTitle(company, department, route.byProject);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: requestType.name,
|
||||||
|
sections: items.map(i => ({ title: title(i.route), rows: buildRouteRows(i.steps, employees) }))
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user