1 Commits

Author SHA1 Message Date
gorbenko.pavel 6a01101054 Добавить вкладку «Выгрузка маршрутов» и перенести «Доступ к заявкам» вниз меню
Выбранные типы заявок выгружаются в xlsx (лист на тип: №, роль, исполнитель,
условие) через POST /api/reports/routes. Пункт меню стоит после «Проверки
маршрута», «Доступ к заявкам» — последним.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:18:51 +05:00
47 changed files with 288 additions and 1032 deletions
@@ -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);
} }
} }
+1 -4
View File
@@ -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,43 +72,23 @@ public static class RouteExportWorkbook
rowIndex++; rowIndex++;
} }
// Шапка колонок — один раз на маршрут: исполнители идут сразу под var header = sheet.CreateRow(rowIndex++);
// согласующими, их отделяет только строка-подпись. for (var i = 0; i < Columns.Length; i++)
var firstGroup = true;
foreach (var group in section.Groups)
{ {
var captionRow = sheet.CreateRow(rowIndex); var cell = header.CreateCell(i);
var captionCell = captionRow.CreateCell(0); cell.SetCellValue(Columns[i].Title);
captionCell.SetCellValue(group.Caption); cell.CellStyle = headerStyle;
captionCell.CellStyle = titleStyle; }
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
rowIndex++;
if (firstGroup) foreach (var row in section.Rows)
{ {
var header = sheet.CreateRow(rowIndex++); var excelRow = sheet.CreateRow(rowIndex++);
for (var i = 0; i < Columns.Length; i++) var numberCell = excelRow.CreateCell(0);
{ numberCell.SetCellValue(row.Number);
var cell = header.CreateCell(i); numberCell.CellStyle = cellStyle;
cell.SetCellValue(Columns[i].Title); WriteText(excelRow, 1, row.Role, cellStyle);
cell.CellStyle = headerStyle; WriteText(excelRow, 2, row.Executor, cellStyle);
} WriteText(excelRow, 3, row.Condition, cellStyle);
}
firstGroup = false;
foreach (var row in group.Rows)
{
var excelRow = sheet.CreateRow(rowIndex++);
var numberCell = excelRow.CreateCell(0);
numberCell.SetCellValue(row.Number);
numberCell.CellStyle = cellStyle;
WriteText(excelRow, 1, row.Role, cellStyle);
WriteText(excelRow, 2, row.Executor, cellStyle);
WriteText(excelRow, 3, row.Organization, cellStyle);
WriteText(excelRow, 4, row.Condition, cellStyle);
WriteText(excelRow, 5, row.Comment, cellStyle);
}
} }
} }
} }
@@ -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);
@@ -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
View File
@@ -1 +1 @@
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/ @tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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} />
)} )}
@@ -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
@@ -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;
}
@@ -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={employees.items.map(toEmployeeOption)}
options={employeeOptions} value={email}
value="" 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={setEmail}
onChange={value => value && setEmails(current => [...current, value])} />
/>
<div className={styles.chosen}>
{emails.length === 0 && <span className={styles.chosenEmpty}>{texts.requestTypeAccess.noEmailsSelected}</span>}
{emails.map(email => (
<div key={email} className={styles.chosenItem}>
<span>{employeeLabel(email)}</span>
<IconButton
icon="close"
label={texts.requestTypeAccess.removeEmail}
onClick={() => setEmails(current => current.filter(x => x !== email))}
/>
</div>
))}
</div>
</div>
</Field> </Field>
</FormGrid> </FormGrid>
</FormModal> </FormModal>
@@ -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,51 +30,21 @@ 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); <Modal
// Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл. title={title}
const requestClose = (): void => { onClose={onClose}
if (isSaving) return; footer={
if (isDirty) setConfirmingClose(true); <>
else onClose(); <Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
}; <Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
{isSaving ? savingLabel : submitLabel}
return ( </Button>
<> </>
<Modal }
title={title} >
onClose={requestClose} {error && <Alert kind="err">{error}</Alert>}
footer={ {children}
<> </Modal>
{onReset && canReset && ( );
<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}>
{isSaving ? savingLabel : submitLabel}
</Button>
</>
}
>
{error && <Alert kind="err">{error}</Alert>}
{children}
</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> {
+8 -17
View File
@@ -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) }))
};
}