Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a46782920b | |||
| f3b0f0ad48 | |||
| 0e6a7064d2 | |||
| 05f73d356a | |||
| 82c2dc36a6 | |||
| b959feedbf | |||
| dcdfcdd396 | |||
| a8bfd9f456 | |||
| 1bf8d4ca11 |
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Auth;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Заявки, поданные по одному основанию (СТ, потребность). Видны все, независимо
|
||||
/// от прав вызывающего на элементы; CanOpen говорит, может ли он открыть заявку.
|
||||
/// Только пользовательский токен: без email права проверять не для кого.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthPolicies.Delegated)]
|
||||
[Route("api/basis-requests")]
|
||||
[Produces("application/json")]
|
||||
public sealed class BasisRequestsController(BasisRequestsService service) : ControllerBase
|
||||
{
|
||||
/// <summary>Заявки типа requestTypeId с BasisKey = basisKey по всем партициям.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<BasisRequestDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Get(
|
||||
[FromQuery] int requestTypeId,
|
||||
// string? — пустой параметр фронтенд вырезает из URL целиком (см. RequestManageController.Search).
|
||||
[FromQuery] string? basisKey,
|
||||
CancellationToken ct) =>
|
||||
(await service.GetByBasisAsync(requestTypeId, basisKey ?? string.Empty, ct)).ToActionResult();
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthPolicies.Any)]
|
||||
[Route("api/reports")]
|
||||
public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase
|
||||
public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
|
||||
{
|
||||
private const string XlsxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
@@ -51,4 +51,27 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll
|
||||
|
||||
return File(file, XlsxContentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
||||
/// POST — потому что в теле список id типов (см. RouteExportService).
|
||||
/// </summary>
|
||||
[HttpPost("routes")]
|
||||
[Produces(XlsxContentType)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Routes([FromBody] RouteExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await routeExport.BuildAsync(request.RequestTypeIds, ct);
|
||||
if (result.IsFailed)
|
||||
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
|
||||
|
||||
var file = result.Value;
|
||||
|
||||
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||
disposition.SetHttpFileName(file.FileName);
|
||||
Response.Headers.ContentDisposition = disposition.ToString();
|
||||
|
||||
return File(file.Content, XlsxContentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Чтение заявок и маршрутов согласования от имени приложения (см.
|
||||
/// RequestManageService) — для вкладок «Редактирование заявок» и «Заявки по
|
||||
/// согласующему»: результат не зависит от прав вызывающего пользователя на
|
||||
/// списки SharePoint, в отличие от прежнего прямого PnP-чтения с фронтенда.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/request-manage")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RequestManageController(RequestManageService service) : ControllerBase
|
||||
{
|
||||
/// <summary>Заявки типа requestTypeId по всем партициям (архив + текущая), постранично.</summary>
|
||||
[HttpGet("search")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ManagedRequestPageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery] int requestTypeId,
|
||||
// string? (не string) — [ApiController] с Nullable enable иначе считает
|
||||
// query обязательным параметром модели, а IHttpClient.buildUrl (см.
|
||||
// httpClient.ts) вырезает из URL пустые query-параметры целиком: пустое
|
||||
// поле поиска (обычное состояние при открытии комбобокса) присылало
|
||||
// запрос вовсе БЕЗ query, и такой запрос падал 400 ещё до контроллера.
|
||||
[FromQuery] string? query,
|
||||
[FromQuery] int page,
|
||||
[FromQuery] int? pageSize,
|
||||
CancellationToken ct) =>
|
||||
(await service.SearchAsync(requestTypeId, query ?? string.Empty, page < 1 ? 1 : page, pageSize, ct)).ToActionResult();
|
||||
|
||||
/// <summary>Заявка + полный маршрут согласования (обе фазы, по StepOrder).</summary>
|
||||
[HttpGet("matrix")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ManagedRequestMatrixDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Matrix(
|
||||
[FromQuery] int requestTypeId, [FromQuery] string listId, [FromQuery] int requestId, CancellationToken ct) =>
|
||||
(await service.GetMatrixAsync(requestTypeId, listId, requestId, ct)).ToActionResult();
|
||||
|
||||
/// <summary>
|
||||
/// Активные строки согласования одного человека сразу по всем типам заявок и
|
||||
/// партициям. statuses — через запятую (см. REQUEST_STATUS во фронтенде).
|
||||
/// </summary>
|
||||
[HttpGet("by-approver")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<UserApprovalRowDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ByApprover(
|
||||
[FromQuery] string approverEmail, [FromQuery] string? statuses, CancellationToken ct)
|
||||
{
|
||||
var activeStatuses = (statuses ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
|
||||
return (await service.FindActiveApprovalRowsForApproverAsync(approverEmail ?? string.Empty, activeStatuses, ct))
|
||||
.ToActionResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Auth;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Настройка доступа «видеть все заявки типа», а не только свои. Список
|
||||
/// email'ов по типу дёргает и портал заявок (проверить текущего пользователя),
|
||||
/// и Power Automate — поэтому политика AnyAuth, как у RemindersController.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthPolicies.Any)]
|
||||
[Route("api/request-type-access")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RequestTypeAccessController(RequestTypeAccessService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeAccessDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
|
||||
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||
|
||||
[HttpGet("{requestTypeId:int}/emails")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ListEmails(int requestTypeId, CancellationToken ct) =>
|
||||
(await service.ListEmailsAsync(requestTypeId, ct)).ToActionResult();
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ApiResponse<RequestTypeAccessDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create(CreateRequestTypeAccessRequest body, CancellationToken ct) =>
|
||||
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||
}
|
||||
@@ -20,10 +20,14 @@ public sealed class RequestTypeListsController(ListPartitionService service) : C
|
||||
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
|
||||
/// запрашивать серию по каждому типу отдельно.
|
||||
/// </summary>
|
||||
/// <param name="title">
|
||||
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
|
||||
/// без учёта регистра, по вхождению подстроки.
|
||||
/// </param>
|
||||
[HttpGet("lists")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ListAll(CancellationToken ct) =>
|
||||
(await service.ListAllAsync(ct)).ToActionResult();
|
||||
public async Task<IActionResult> ListAll([FromQuery] string? title, CancellationToken ct) =>
|
||||
(await service.ListAllAsync(title, ct)).ToActionResult();
|
||||
|
||||
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
|
||||
[HttpGet("{requestTypeId:int}/lists")]
|
||||
|
||||
@@ -53,7 +53,10 @@ builder.Services.AddCors(options =>
|
||||
options.AddPolicy(CorsPolicyName, policy => policy
|
||||
.WithOrigins(corsAllowedOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod());
|
||||
.AllowAnyMethod()
|
||||
// Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа,
|
||||
// а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов).
|
||||
.WithExposedHeaders("Content-Disposition"));
|
||||
});
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings.*.json" DependentUpon="appsettings.json" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" />
|
||||
|
||||
@@ -2,9 +2,12 @@ using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||
using Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.Reports;
|
||||
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
@@ -17,6 +20,9 @@ public static class ApplicationServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<ReminderService>();
|
||||
services.AddScoped<RequestTypeService>();
|
||||
services.AddScoped<RequestTypeAccessService>();
|
||||
services.AddScoped<RequestManageService>();
|
||||
services.AddScoped<BasisRequestsService>();
|
||||
services.AddScoped<ListPartitionRegistry>();
|
||||
services.AddScoped<ListPartitionService>();
|
||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||
@@ -24,6 +30,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddScoped<AutoApproveService>();
|
||||
services.AddScoped<AutoApproveJob>();
|
||||
services.AddScoped<VacancyReportService>();
|
||||
services.AddScoped<RouteExportService>();
|
||||
services.AddScoped<TemplateRouteService>();
|
||||
services.AddScoped<TemplateRouteStepService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Заявка, поданная по тому же основанию (СТ, потребность). Номер, статус и сумму
|
||||
/// видят все: от них зависит проверка «сумма оплат не больше основания». Ссылку
|
||||
/// на саму заявку фронтенд показывает только при CanOpen.
|
||||
/// </summary>
|
||||
/// <param name="Id">Id элемента списка данных.</param>
|
||||
/// <param name="ListId">Серверный относительный URL списка партиции, где лежит заявка.</param>
|
||||
/// <param name="UniqueCode">RequestUniqueCode; null — номер ещё не присвоен.</param>
|
||||
/// <param name="Status">RequestStatus заявки.</param>
|
||||
/// <param name="Amount">Сумма заявки (PayAmount).</param>
|
||||
/// <param name="CanOpen">У вызывающего есть право просмотра элемента.</param>
|
||||
public sealed record BasisRequestDto(
|
||||
int Id,
|
||||
string ListId,
|
||||
string? UniqueCode,
|
||||
string Status,
|
||||
double Amount,
|
||||
bool CanOpen);
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Заявки, поданные по одному основанию (колонка BasisKey: «ct:<Id>»,
|
||||
/// «need:<oid>») — для таблицы прежних оплат в «Заявке на оплату».
|
||||
///
|
||||
/// Читаем от имени ПРИЛОЖЕНИЯ: права на элементы заявок разорваны поштучно, и
|
||||
/// под своей учёткой заявитель видел бы только собственные заявки — сумма
|
||||
/// прежних оплат по СТ занижалась бы, а проверка превышения пропускала бы
|
||||
/// переплату. Открыть чужую заявку при этом может только тот, у кого на неё
|
||||
/// есть права: CanOpen проверяется по каждому элементу для вызывающего.
|
||||
/// </summary>
|
||||
public sealed class BasisRequestsService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry partitionRegistry,
|
||||
ICurrentUser currentUser)
|
||||
{
|
||||
private const string BasisKeyField = "BasisKey";
|
||||
private const string AmountField = "PayAmount";
|
||||
|
||||
// Больше по одному основанию не бывает: СТ оплачивают частями, но не сотнями.
|
||||
private const int MaxItemsPerPartition = 500;
|
||||
|
||||
// Проверка прав — отдельный запрос на элемент; ограничиваем параллельность,
|
||||
// чтобы не упереться в троттлинг SharePoint (429).
|
||||
private const int MaxParallelPermissionChecks = 4;
|
||||
|
||||
public async Task<Result<IReadOnlyList<BasisRequestDto>>> GetByBasisAsync(
|
||||
int requestTypeId, string basisKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(basisKey))
|
||||
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new ValidationError("Не указано основание (basisKey)."));
|
||||
|
||||
var requestType = await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
if (requestType is null)
|
||||
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Ok<IReadOnlyList<BasisRequestDto>>([]);
|
||||
|
||||
var siteUrl = requestType.SiteUrl;
|
||||
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
|
||||
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||
var listIds = archived.Select(x => x.ListId).Append(current.ListId).ToList();
|
||||
|
||||
// Кавычка в OData-литерале удваивается; сам литерал кодируем — в ключе бывают «:» и прочее.
|
||||
var literal = Uri.EscapeDataString(basisKey.Trim().Replace("'", "''"));
|
||||
var query = $"$select=Id,RequestUniqueCode,RequestStatus,{AmountField}" +
|
||||
$"&$filter={BasisKeyField} eq '{literal}'&$orderby=Id&$top={MaxItemsPerPartition}";
|
||||
|
||||
var found = new List<(JsonElement Item, string ListId)>();
|
||||
foreach (var listId in listIds)
|
||||
{
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listId, query, cancellationToken);
|
||||
found.AddRange(items.Select(i => (i, listId)));
|
||||
}
|
||||
|
||||
var email = currentUser.Email;
|
||||
using var gate = new SemaphoreSlim(MaxParallelPermissionChecks);
|
||||
var result = await Task.WhenAll(found.Select(async x =>
|
||||
{
|
||||
var id = GetInt(x.Item, "Id") ?? 0;
|
||||
var canOpen = false;
|
||||
if (!string.IsNullOrWhiteSpace(email) && id > 0)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
canOpen = await sharePoint.CanUserViewItemAsync(siteUrl, x.ListId, id, email, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return new BasisRequestDto(
|
||||
id,
|
||||
x.ListId,
|
||||
GetString(x.Item, "RequestUniqueCode"),
|
||||
GetString(x.Item, "RequestStatus") ?? string.Empty,
|
||||
GetDouble(x.Item, AmountField) ?? 0,
|
||||
canOpen);
|
||||
}));
|
||||
|
||||
return Result.Ok<IReadOnlyList<BasisRequestDto>>(result);
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static int? GetInt(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
|
||||
|
||||
private static double? GetDouble(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d;
|
||||
if (v.ValueKind == JsonValueKind.String &&
|
||||
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||
return parsed;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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()
|
||||
};
|
||||
}
|
||||
@@ -78,7 +78,12 @@ public sealed class ListPartitionService(
|
||||
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
|
||||
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
|
||||
/// </summary>
|
||||
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(CancellationToken cancellationToken)
|
||||
/// <param name="title">
|
||||
/// Необязательный фильтр по заголовку партиции (Title списка SharePoint),
|
||||
/// без учёта регистра, по вхождению подстроки. null — фильтр не применяется.
|
||||
/// </param>
|
||||
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(
|
||||
string? title, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestTypes = await db.RequestTypes
|
||||
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||
@@ -88,6 +93,14 @@ public sealed class ListPartitionService(
|
||||
foreach (var requestType in requestTypes)
|
||||
{
|
||||
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
partitions = partitions
|
||||
.Where(x => x.Title is not null && x.Title.Contains(title, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (partitions.Count == 0) continue;
|
||||
|
||||
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using NPOI.SS.UserModel;
|
||||
using NPOI.SS.Util;
|
||||
using NPOI.XSSF.UserModel;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
|
||||
public sealed record RouteExportRow(
|
||||
int Number,
|
||||
string Role,
|
||||
string Executor,
|
||||
string Organization,
|
||||
string Condition,
|
||||
string Comment);
|
||||
|
||||
/// <summary>Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация).</summary>
|
||||
public sealed record RouteExportGroup(string Caption, IReadOnlyList<RouteExportRow> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
|
||||
/// (по компании/отделу) и их нужно различать на одном листе; у единственного
|
||||
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
|
||||
/// </summary>
|
||||
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
|
||||
|
||||
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
||||
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
|
||||
|
||||
/// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
|
||||
public sealed record RouteExportRequest(IReadOnlyList<int>? RequestTypeIds);
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
||||
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
||||
///
|
||||
/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
|
||||
/// <see cref="RouteExportService"/>.
|
||||
/// </summary>
|
||||
public static class RouteExportWorkbook
|
||||
{
|
||||
private const int MaxSheetNameLength = 31;
|
||||
private const string InvalidSheetNameChars = "[]:*?/\\";
|
||||
|
||||
private static readonly (string Title, double Width)[] Columns =
|
||||
[
|
||||
(" ", 6.0),
|
||||
("Роль ", 41.1),
|
||||
("Исполнитель ", 42.2),
|
||||
("Организация ", 28.0),
|
||||
("Условие ", 66.2),
|
||||
("Комментарий ", 36.0),
|
||||
];
|
||||
|
||||
public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
|
||||
{
|
||||
var workbook = new XSSFWorkbook();
|
||||
|
||||
var headerStyle = CreateStyle(workbook, bold: true);
|
||||
var cellStyle = CreateStyle(workbook, bold: false);
|
||||
var titleStyle = CreateStyle(workbook, bold: true, border: false);
|
||||
|
||||
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var sheetData in sheets)
|
||||
{
|
||||
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
sheet.SetColumnWidth(i, (int)(Columns[i].Width * 256));
|
||||
|
||||
var rowIndex = 0;
|
||||
foreach (var section in sheetData.Sections)
|
||||
{
|
||||
if (rowIndex > 0) rowIndex++; // пустая строка только между маршрутами
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(section.Title))
|
||||
{
|
||||
var titleRow = sheet.CreateRow(rowIndex);
|
||||
var titleCell = titleRow.CreateCell(0);
|
||||
titleCell.SetCellValue(section.Title);
|
||||
titleCell.CellStyle = titleStyle;
|
||||
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
// Шапка колонок — один раз на маршрут: исполнители идут сразу под
|
||||
// согласующими, их отделяет только строка-подпись.
|
||||
var firstGroup = true;
|
||||
foreach (var group in section.Groups)
|
||||
{
|
||||
var captionRow = sheet.CreateRow(rowIndex);
|
||||
var captionCell = captionRow.CreateCell(0);
|
||||
captionCell.SetCellValue(group.Caption);
|
||||
captionCell.CellStyle = titleStyle;
|
||||
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
||||
rowIndex++;
|
||||
|
||||
if (firstGroup)
|
||||
{
|
||||
var header = sheet.CreateRow(rowIndex++);
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
{
|
||||
var cell = header.CreateCell(i);
|
||||
cell.SetCellValue(Columns[i].Title);
|
||||
cell.CellStyle = headerStyle;
|
||||
}
|
||||
}
|
||||
|
||||
firstGroup = false;
|
||||
|
||||
foreach (var row in group.Rows)
|
||||
{
|
||||
var excelRow = sheet.CreateRow(rowIndex++);
|
||||
var numberCell = excelRow.CreateCell(0);
|
||||
numberCell.SetCellValue(row.Number);
|
||||
numberCell.CellStyle = cellStyle;
|
||||
WriteText(excelRow, 1, row.Role, cellStyle);
|
||||
WriteText(excelRow, 2, row.Executor, cellStyle);
|
||||
WriteText(excelRow, 3, row.Organization, cellStyle);
|
||||
WriteText(excelRow, 4, row.Condition, cellStyle);
|
||||
WriteText(excelRow, 5, row.Comment, cellStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.Write(stream, leaveOpen: true);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteText(IRow row, int column, string value, ICellStyle style)
|
||||
{
|
||||
var cell = row.CreateCell(column);
|
||||
cell.SetCellValue(value);
|
||||
cell.CellStyle = style;
|
||||
}
|
||||
|
||||
private static ICellStyle CreateStyle(IWorkbook workbook, bool bold, bool border = true)
|
||||
{
|
||||
var font = workbook.CreateFont();
|
||||
font.IsBold = bold;
|
||||
|
||||
var style = workbook.CreateCellStyle();
|
||||
style.SetFont(font);
|
||||
style.WrapText = true;
|
||||
style.VerticalAlignment = VerticalAlignment.Top;
|
||||
if (border)
|
||||
{
|
||||
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
// Имя листа Excel: до 31 символа, без []:*?/\, уникальное без учёта регистра.
|
||||
private static string UniqueSheetName(string raw, HashSet<string> used)
|
||||
{
|
||||
var cleaned = new string((raw ?? string.Empty)
|
||||
.Select(c => InvalidSheetNameChars.Contains(c) ? ' ' : c)
|
||||
.ToArray()).Trim();
|
||||
if (cleaned.Length == 0) cleaned = "Лист";
|
||||
|
||||
var candidate = Truncate(cleaned, MaxSheetNameLength);
|
||||
for (var n = 2; !used.Add(candidate); n++)
|
||||
{
|
||||
var suffix = $" ({n})";
|
||||
candidate = Truncate(cleaned, MaxSheetNameLength - suffix.Length) + suffix;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int length) =>
|
||||
value.Length <= length ? value : value[..length].TrimEnd();
|
||||
}
|
||||
@@ -26,7 +26,7 @@ internal sealed record SelectionRow(
|
||||
/// </summary>
|
||||
public const string Select =
|
||||
"$select=Id,RequestStatus,Position,DepartmentName,ObjectProject,OpenVacanciesCount," +
|
||||
"SubmitDate,Created,OpenReason,OpenReasonDetails,Responsible/Title&$expand=Responsible&$top=5000";
|
||||
"SubmitDate,Created,VacancyOpenReason,VacancyOpenReasonDetails,Responsible/Title&$expand=Responsible&$top=5000";
|
||||
|
||||
public static SelectionRow? TryParse(JsonElement item, string listId)
|
||||
{
|
||||
@@ -50,7 +50,7 @@ internal sealed record SelectionRow(
|
||||
// иначе заявка попадала бы в отчёт, но с пустой колонкой H.
|
||||
SharePointJson.Date(item, "SubmitDate") ?? SharePointJson.Date(item, "Created"),
|
||||
SharePointJson.PersonTitle(item, "Responsible"),
|
||||
SharePointJson.String(item, "OpenReason"),
|
||||
SharePointJson.String(item, "OpenReasonDetails"));
|
||||
SharePointJson.String(item, "VacancyOpenReason"),
|
||||
SharePointJson.String(item, "VacancyOpenReasonDetails"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
/// индекса отчёту не критично. Но на этом тенанте ЧАСТЬ нужных полей индекс не
|
||||
/// отдаёт вовсе, при том что в самих списках значения есть (проверено поштучно
|
||||
/// через REST):
|
||||
/// • «Заявка на подбор»: OpenReason, OpenReasonDetails;
|
||||
/// • «Заявка на подбор»: VacancyOpenReason, VacancyOpenReasonDetails;
|
||||
/// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee;
|
||||
/// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под
|
||||
/// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…).
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestManage;
|
||||
|
||||
// Формы 1:1 с фронтендом (frontend/src/webparts/approvalAdmin/models/requestManage.ts,
|
||||
// manageByUser.ts) — эти DTO заменяют собой то, что раньше собирал PnP-клиент
|
||||
// НАПРЯМУЮ в SharePoint от имени текущего пользователя (см. requestManageService.ts):
|
||||
// тут это делает бэкенд от имени приложения (см. ISharePointClient), поэтому
|
||||
// результат не зависит от прав конкретного пользователя на списки SharePoint.
|
||||
|
||||
/// <summary>Заявка (строка списка «… — данные»), как её видит комбобокс выбора заявки.</summary>
|
||||
public sealed record ManagedRequestSummaryDto(
|
||||
int Id,
|
||||
string Title,
|
||||
string Status,
|
||||
/// <summary>ISO 8601 или пустая строка.</summary>
|
||||
string Created,
|
||||
string AuthorName,
|
||||
string? UniqueCode,
|
||||
/// <summary>Партиция (серверный относительный url списка данных), где физически лежит заявка.</summary>
|
||||
string ListId);
|
||||
|
||||
public sealed record ManagedRequestPageDto(IReadOnlyList<ManagedRequestSummaryDto> Items, bool HasMore);
|
||||
|
||||
/// <summary>Строка списка «… — согласование».</summary>
|
||||
public sealed record ManagedApprovalRowDto(
|
||||
int Id,
|
||||
int TicketId,
|
||||
int? ApproverId,
|
||||
string ApproverName,
|
||||
string ApproverEmail,
|
||||
string Position,
|
||||
string Status,
|
||||
/// <summary>ISO 8601 или пустая строка — форматирование под «дд.мм.гггг» остаётся на фронтенде.</summary>
|
||||
string DateIso,
|
||||
string Comment,
|
||||
double Order,
|
||||
bool AllowReassign,
|
||||
string Phase,
|
||||
int? SlaHours,
|
||||
bool? AskOverdueReason);
|
||||
|
||||
/// <summary>Заявка + полный маршрут (обе фазы) — для экрана редактирования заявок.</summary>
|
||||
public sealed record ManagedRequestMatrixDto(
|
||||
ManagedRequestSummaryDto Request,
|
||||
string SiteUrl,
|
||||
string DataListTitle,
|
||||
string DataListUrl,
|
||||
string ApprovalListTitle,
|
||||
IReadOnlyList<ManagedApprovalRowDto> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Строка согласования, найденная сразу по ВСЕМ типам заявок и партициям — для
|
||||
/// экрана «Заявки по согласующему» (UserApprovalRow во фронтенде — тот же набор
|
||||
/// полей, что и ManagedApprovalRowDto, только развёрнутый, а не вложенный).
|
||||
/// </summary>
|
||||
public sealed record UserApprovalRowDto(
|
||||
int Id,
|
||||
int TicketId,
|
||||
int? ApproverId,
|
||||
string ApproverName,
|
||||
string ApproverEmail,
|
||||
string Position,
|
||||
string Status,
|
||||
string DateIso,
|
||||
string Comment,
|
||||
double Order,
|
||||
bool AllowReassign,
|
||||
string Phase,
|
||||
int? SlaHours,
|
||||
bool? AskOverdueReason,
|
||||
int RequestTypeId,
|
||||
string RequestTypeName,
|
||||
string SiteUrl,
|
||||
string ListId,
|
||||
string ApprovalListTitle,
|
||||
string RequestTitle,
|
||||
string? RequestUniqueCode);
|
||||
@@ -0,0 +1,444 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestManage;
|
||||
|
||||
/// <summary>
|
||||
/// Чтение заявок и их маршрутов согласования для вкладок «Редактирование заявок»
|
||||
/// и «Заявки по согласующему» — от имени ПРИЛОЖЕНИЯ (см. ISharePointClient), а не
|
||||
/// текущего пользователя.
|
||||
///
|
||||
/// До появления этого сервиса те же данные читал PnP-клиент прямо во фронтенде
|
||||
/// (см. requestManageService.ts) от имени залогиненного администратора — и
|
||||
/// список видимых заявок молча урезался правами САМОГО администратора на списки
|
||||
/// SharePoint (item-level security), хотя вкладка задумана как «видно всё».
|
||||
/// Правка строк маршрута (переназначить/удалить/переставить) остаётся на
|
||||
/// фронтенде через PnP — сюда вынесено только чтение.
|
||||
/// </summary>
|
||||
public sealed class RequestManageService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry partitionRegistry,
|
||||
ILogger<RequestManageService> logger)
|
||||
{
|
||||
private const int DefaultPageSize = 20;
|
||||
|
||||
// Сколько типов заявок читаем из SharePoint одновременно. Без ограничения
|
||||
// 18 типов × партиции разом упирались в троттлинг (429), а ошибки глотались
|
||||
// и экран показывал пустой список.
|
||||
private const int MaxParallelTypes = 3;
|
||||
|
||||
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
||||
// requestManageService.ts (тот же самый список SharePoint, тот же провижининг).
|
||||
private const string ApproverField = "TansuSiteApprover";
|
||||
private const string StatusField = "ApprovalStatus";
|
||||
private const string TicketIdField = "TicketId";
|
||||
private const string DateField = "Date";
|
||||
private const string CommentField = "Comment";
|
||||
private const string OrderField = "StepOrder";
|
||||
private const string AllowReassignField = "AllowReassignToOtherApprovers";
|
||||
private const string PhaseField = "Phase";
|
||||
private const string SlaHoursField = "SLAhours";
|
||||
private const string AskOverdueReasonField = "AskOverdueReason";
|
||||
|
||||
private const string ApprovalRowSelect =
|
||||
$"$select=Id,Title,{StatusField},{DateField},{CommentField},{OrderField},{AllowReassignField},{PhaseField}," +
|
||||
$"{SlaHoursField},{AskOverdueReasonField},{TicketIdField},{ApproverField}/Id,{ApproverField}/Title,{ApproverField}/EMail" +
|
||||
$"&$expand={ApproverField}";
|
||||
|
||||
private const string DataRowSelect = "$select=Id,Title,RequestStatus,Created,Author/Title,RequestUniqueCode&$expand=Author";
|
||||
|
||||
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
||||
// (см. requestManageService.ts/DATA_LIST_SUFFIX).
|
||||
private static readonly Regex DataListSuffix = new(@"\s*—\s*данные\s*$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая),
|
||||
/// постранично — для комбобокса выбора заявки (см. ManageRequestsScreen).
|
||||
/// </summary>
|
||||
public async Task<Result<ManagedRequestPageDto>> SearchAsync(
|
||||
int requestTypeId, string query, int page, int? pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestType = await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
if (requestType is null)
|
||||
return Result.Fail<ManagedRequestPageDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Ok(new ManagedRequestPageDto([], false));
|
||||
|
||||
var size = pageSize is > 0 ? pageSize.Value : DefaultPageSize;
|
||||
var siteUrl = requestType.SiteUrl;
|
||||
|
||||
var partitionIds = await GetAllPartitionListIdsAsync(requestType, cancellationToken);
|
||||
|
||||
var all = new List<ManagedRequestSummaryDto>();
|
||||
foreach (var listId in partitionIds)
|
||||
{
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, listId, $"{DataRowSelect}&$top=5000", cancellationToken);
|
||||
all.AddRange(items.Select(i => ToSummary(i, listId)));
|
||||
}
|
||||
|
||||
var words = SplitQueryWords(query);
|
||||
var filtered = all.Where(x => MatchesQuery(x, words))
|
||||
// Тот же порядок, что и раньше (Search сортировал по Created по возрастанию).
|
||||
.OrderBy(x => x.Created, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var pageItems = filtered.Skip((page - 1) * size).Take(size).ToList();
|
||||
return Result.Ok(new ManagedRequestPageDto(pageItems, page * size < filtered.Count));
|
||||
}
|
||||
|
||||
/// <summary>Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.</summary>
|
||||
public async Task<Result<ManagedRequestMatrixDto>> GetMatrixAsync(
|
||||
int requestTypeId, string listId, int requestId, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestType = await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
if (requestType is null)
|
||||
return Result.Fail<ManagedRequestMatrixDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Fail<ManagedRequestMatrixDto>(
|
||||
new ValidationError($"У типа заявки {requestTypeId} не указан сайт SharePoint."));
|
||||
|
||||
var siteUrl = requestType.SiteUrl;
|
||||
|
||||
var rawRequestItems = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, listId, $"{DataRowSelect}&$filter=Id eq {requestId}&$top=1", cancellationToken);
|
||||
var rawRequest = rawRequestItems.FirstOrDefault();
|
||||
if (rawRequest.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
return Result.Fail<ManagedRequestMatrixDto>(new NotFoundError("Заявка", requestId));
|
||||
|
||||
var request = ToSummary(rawRequest, listId);
|
||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken) ?? string.Empty;
|
||||
var dataListUrl = ListAbsUrl(siteUrl, listId);
|
||||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||||
|
||||
if (approvalListTitle is null)
|
||||
return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, string.Empty, []));
|
||||
|
||||
var rawRows = await sharePoint.GetItemsByListTitleAsync(
|
||||
siteUrl, approvalListTitle,
|
||||
$"{ApprovalRowSelect}&$filter={TicketIdField} eq {requestId}&$orderby={OrderField}", cancellationToken);
|
||||
|
||||
var rows = rawRows.Select(ToApprovalRow).ToList();
|
||||
return Result.Ok(new ManagedRequestMatrixDto(request, siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Активные строки согласования данного человека сразу по всем типам заявок и
|
||||
/// всем их партициям (архив + текущая) — для экрана «Заявки по согласующему».
|
||||
/// </summary>
|
||||
public async Task<Result<IReadOnlyList<UserApprovalRowDto>>> FindActiveApprovalRowsForApproverAsync(
|
||||
string approverEmail, IReadOnlyList<string> activeStatuses, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(approverEmail) || activeStatuses.Count == 0)
|
||||
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>([]);
|
||||
|
||||
var requestTypes = await db.RequestTypes.AsNoTracking()
|
||||
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var email = approverEmail.Trim();
|
||||
|
||||
// DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО,
|
||||
// и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше
|
||||
// GetArchivedAsync вызывался из параллельных задач — «вторая операция на
|
||||
// контексте» падала, а пустой catch превращал это в пустой список.
|
||||
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||||
foreach (var rt in requestTypes)
|
||||
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
|
||||
|
||||
using var gate = new SemaphoreSlim(MaxParallelTypes);
|
||||
|
||||
// Search не поймать и не подменить обходом списков: если он недоступен, это
|
||||
// видно по ошибке запроса, а не по тихо неполному результату.
|
||||
var rows = await FindViaSearchAsync(requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken);
|
||||
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(rows);
|
||||
}
|
||||
|
||||
private const string SearchApproverProperty = "TansuSiteApproverOWSUSER";
|
||||
private const string SearchStatusProperty = "ApprovalStatusOWSCHCS";
|
||||
|
||||
private static readonly Regex ApprovalListSuffix = new(@"\s*—\s*согласование\s*$", RegexOptions.Compiled);
|
||||
|
||||
private static UserApprovalRowDto ToUserRow(
|
||||
ManagedApprovalRowDto r, RequestType requestType, string siteUrl, string dataListId, string approvalListTitle,
|
||||
ManagedRequestSummaryDto? dataRow) =>
|
||||
new(
|
||||
r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso,
|
||||
r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason,
|
||||
requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle,
|
||||
dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode);
|
||||
|
||||
/// <summary>
|
||||
/// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН
|
||||
/// запрос Search на сайт (по человеку и статусам, сразу по всем спискам
|
||||
/// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования
|
||||
/// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные
|
||||
/// строки дочитываются по REST — только в тех списках, где они реально нашлись.
|
||||
/// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<UserApprovalRowDto>> FindViaSearchAsync(
|
||||
IReadOnlyList<RequestType> requestTypes,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||
IReadOnlyList<string> activeStatuses,
|
||||
string approverEmail,
|
||||
SemaphoreSlim gate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var email = approverEmail.Replace("\"", string.Empty);
|
||||
var statusQuery = string.Join(
|
||||
" OR ", activeStatuses.Select(s => $"{SearchStatusProperty}:\"{s.Replace("\"", string.Empty)}\""));
|
||||
var kql = $"{SearchApproverProperty}:\"{email}\" ({statusQuery}) contentclass:STS_ListItem_GenericList";
|
||||
|
||||
var result = new List<UserApprovalRowDto>();
|
||||
foreach (var siteUrl in requestTypes.Select(x => x.SiteUrl!).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var hits = await sharePoint.SearchAsync(siteUrl, kql, ["Path", "ListItemID"], cancellationToken);
|
||||
|
||||
// Путь вида https://host/sites/x/Lists/L/DispForm.aspx?ID=5 → серверный url списка.
|
||||
var byList = hits
|
||||
.Select(h => (
|
||||
Url: ListUrlFromHitPath(h.GetValueOrDefault("Path")),
|
||||
Id: int.TryParse(h.GetValueOrDefault("ListItemID"), out var id) ? id : 0))
|
||||
.Where(x => x.Url is not null && x.Id > 0)
|
||||
.GroupBy(x => x.Url!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var siteTypes = requestTypes
|
||||
.Where(x => string.Equals(x.SiteUrl, siteUrl, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var perList = await Task.WhenAll(byList.Select(async group =>
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
return await ReadSearchHitListAsync(
|
||||
siteUrl, group.Key, group.Select(x => x.Id).Distinct().ToList(), siteTypes, archivedByType,
|
||||
activeStatuses, approverEmail, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}));
|
||||
|
||||
result.AddRange(perList.SelectMany(x => x));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Пара «список согласования → (его заголовок, адрес парного списка данных)»
|
||||
// не меняется, пока список не переименуют, — запоминаем на время жизни процесса,
|
||||
// чтобы не тратить два запроса к SharePoint на каждый вызов. Ключ — адрес списка.
|
||||
private static readonly ConcurrentDictionary<string, (string ApprovalTitle, string DataListUrl)> ListPairs =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<(string ApprovalTitle, string DataListUrl)?> ResolveListPairAsync(
|
||||
string siteUrl, string approvalListUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ListPairs.TryGetValue(approvalListUrl, out var cached)) return cached;
|
||||
|
||||
var approvalList = await sharePoint.GetListByUrlAsync(siteUrl, approvalListUrl, cancellationToken);
|
||||
if (approvalList is null || !ApprovalListSuffix.IsMatch(approvalList.Title)) return null;
|
||||
|
||||
var dataTitle = ApprovalListSuffix.Replace(approvalList.Title, " — данные");
|
||||
var dataList = await sharePoint.GetListByTitleAsync(siteUrl, dataTitle, cancellationToken);
|
||||
if (dataList is null) return null;
|
||||
|
||||
var pair = (approvalList.Title, dataList.ServerRelativeUrl);
|
||||
ListPairs[approvalListUrl] = pair;
|
||||
return pair;
|
||||
}
|
||||
|
||||
private static string? ListUrlFromHitPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Uri.TryCreate(path, UriKind.Absolute, out var uri)) return null;
|
||||
|
||||
var local = Uri.UnescapeDataString(uri.AbsolutePath);
|
||||
var cut = local.LastIndexOf('/');
|
||||
return cut > 0 ? local[..cut] : null;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<UserApprovalRowDto>> ReadSearchHitListAsync(
|
||||
string siteUrl,
|
||||
string approvalListUrl,
|
||||
IReadOnlyList<int> itemIds,
|
||||
IReadOnlyList<RequestType> siteTypes,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||
IReadOnlyList<string> activeStatuses,
|
||||
string approverEmail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pair = await ResolveListPairAsync(siteUrl, approvalListUrl, cancellationToken);
|
||||
if (pair is null) return [];
|
||||
var (approvalTitle, dataListUrl) = pair.Value;
|
||||
|
||||
// Какому типу заявок принадлежит эта партиция (текущая или архивная).
|
||||
var requestType = siteTypes.FirstOrDefault(rt =>
|
||||
string.Equals(rt.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)
|
||||
|| archivedByType[rt.Id].Any(a =>
|
||||
string.Equals(a.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)));
|
||||
if (requestType is null) return [];
|
||||
|
||||
var rows = new List<ManagedApprovalRowDto>();
|
||||
foreach (var chunk in itemIds.Chunk(50))
|
||||
{
|
||||
var filter = string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, approvalListUrl, $"{ApprovalRowSelect}&$filter={filter}&$top={chunk.Length}",
|
||||
cancellationToken);
|
||||
rows.AddRange(items.Select(ToApprovalRow));
|
||||
}
|
||||
|
||||
var matched = rows
|
||||
.Where(r => activeStatuses.Contains(r.Status)
|
||||
&& string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (matched.Count == 0) return [];
|
||||
|
||||
var dataById = await ReadDataRowsAsync(
|
||||
siteUrl, dataListUrl, matched.Select(r => r.TicketId).Distinct().ToList(),
|
||||
cancellationToken);
|
||||
|
||||
return matched
|
||||
.Select(r => ToUserRow(
|
||||
r, requestType, siteUrl, dataListUrl, approvalTitle,
|
||||
dataById.GetValueOrDefault(r.TicketId)))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "by-approver: список «{ApprovalListUrl}» из Search пропущен", approvalListUrl);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
|
||||
string siteUrl, string listId, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ticketIds.Count == 0) return new Dictionary<int, ManagedRequestSummaryDto>();
|
||||
|
||||
var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}"));
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||
siteUrl, listId, $"{DataRowSelect}&$filter={filter}&$top={ticketIds.Count}", cancellationToken);
|
||||
|
||||
return items.Select(i => ToSummary(i, listId)).ToDictionary(x => x.Id);
|
||||
}
|
||||
|
||||
/// <summary>Все партиции типа заявок (архив + текущая), только их listId — для поиска.</summary>
|
||||
private async Task<IReadOnlyList<string>> GetAllPartitionListIdsAsync(
|
||||
RequestType requestType, CancellationToken cancellationToken)
|
||||
{
|
||||
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
|
||||
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||
return archived.Select(x => x.ListId).Append(current.ListId).ToList();
|
||||
}
|
||||
|
||||
private static ManagedRequestSummaryDto ToSummary(JsonElement item, string listId) =>
|
||||
new(
|
||||
GetInt(item, "Id") ?? 0,
|
||||
GetString(item, "Title") is { Length: > 0 } title ? title : $"#{GetInt(item, "Id") ?? 0}",
|
||||
GetString(item, "RequestStatus") ?? string.Empty,
|
||||
GetString(item, "Created") ?? string.Empty,
|
||||
item.TryGetProperty("Author", out var author) && author.ValueKind == JsonValueKind.Object
|
||||
? GetString(author, "Title") ?? string.Empty
|
||||
: string.Empty,
|
||||
GetString(item, "RequestUniqueCode"),
|
||||
listId);
|
||||
|
||||
private static ManagedApprovalRowDto ToApprovalRow(JsonElement item)
|
||||
{
|
||||
var approver = item.TryGetProperty(ApproverField, out var a) && a.ValueKind == JsonValueKind.Object ? a : (JsonElement?)null;
|
||||
|
||||
return new ManagedApprovalRowDto(
|
||||
GetInt(item, "Id") ?? 0,
|
||||
GetInt(item, TicketIdField) ?? 0,
|
||||
approver is { } ap ? GetInt(ap, "Id") : null,
|
||||
approver is { } an ? GetString(an, "Title") ?? string.Empty : string.Empty,
|
||||
approver is { } ae ? GetString(ae, "EMail") ?? string.Empty : string.Empty,
|
||||
GetString(item, "Title") ?? string.Empty,
|
||||
GetString(item, StatusField) ?? string.Empty,
|
||||
GetString(item, DateField) ?? string.Empty,
|
||||
GetString(item, CommentField) ?? string.Empty,
|
||||
GetDouble(item, OrderField) ?? 0,
|
||||
GetBool(item, AllowReassignField) ?? false,
|
||||
GetString(item, PhaseField) is { Length: > 0 } phase ? phase : "Согласование",
|
||||
GetInt(item, SlaHoursField),
|
||||
GetBool(item, AskOverdueReasonField));
|
||||
}
|
||||
|
||||
private static string? ApprovalListTitleFor(string dataListTitle) =>
|
||||
DataListSuffix.IsMatch(dataListTitle)
|
||||
? $"{DataListSuffix.Replace(dataListTitle, string.Empty)} — согласование"
|
||||
: null;
|
||||
|
||||
private static string SiteOrigin(string siteUrl)
|
||||
{
|
||||
var match = Regex.Match(siteUrl, "^https?://[^/]+");
|
||||
return match.Success ? match.Value : siteUrl;
|
||||
}
|
||||
|
||||
private static string ListAbsUrl(string siteUrl, string listId) => $"{SiteOrigin(siteUrl)}{listId}";
|
||||
|
||||
// KQL во фронтенде терпел только буквы/цифры/пробелы (см. sanitizeForKql в
|
||||
// requestManageService.ts) — здесь та же санитизация для сравнения без учёта
|
||||
// регистра по Title/UniqueCode, только вместо полнотекстового индекса Search
|
||||
// сравниваем подстроки напрямую (см. MatchesQuery).
|
||||
private static IReadOnlyList<string> SplitQueryWords(string query)
|
||||
{
|
||||
var sanitized = Regex.Replace(query, @"[^a-zA-Zа-яА-ЯёЁ0-9\s]", " ");
|
||||
return sanitized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private static bool MatchesQuery(ManagedRequestSummaryDto summary, IReadOnlyList<string> words)
|
||||
{
|
||||
if (words.Count == 0) return true;
|
||||
|
||||
var haystack = $"{summary.Title} {summary.UniqueCode}".ToLowerInvariant();
|
||||
return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static int? GetInt(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i)) return i;
|
||||
if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? GetDouble(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d;
|
||||
if (v.ValueKind == JsonValueKind.String && double.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? GetBool(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
if (v.ValueKind is JsonValueKind.True or JsonValueKind.False) return v.GetBoolean();
|
||||
if (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
|
||||
/// <param name="SharePointLists">
|
||||
/// Вся серия SharePoint-списков данных (архив + текущая партиция) типа заявки —
|
||||
/// именно на них нужно выдать email'у право чтения, иначе поиск по порталу
|
||||
/// продолжит отдавать только его собственные заявки: доступ здесь настраивает
|
||||
/// видимость в UI approval-route, но не права SharePoint, которые остаются
|
||||
/// отдельным ручным шагом.
|
||||
/// </param>
|
||||
public sealed record RequestTypeAccessDto(
|
||||
int Id,
|
||||
int RequestTypeId,
|
||||
string Email,
|
||||
IReadOnlyList<RequestListPartitionDto> SharePointLists);
|
||||
|
||||
public sealed record CreateRequestTypeAccessRequest(int RequestTypeId, string Email);
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
|
||||
public sealed class RequestTypeAccessService(
|
||||
ApprovalRouteDbContext db,
|
||||
ListPartitionService partitions,
|
||||
ICurrentUser currentUser,
|
||||
ILogger<RequestTypeAccessService> logger)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<RequestTypeAccessDto>>> ListAsync(
|
||||
int? requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var entities = await db.RequestTypeAccesses.AsNoTracking()
|
||||
.Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId)
|
||||
.OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Партиции запрашиваются РАЗ на тип заявки, а не на строку — иначе список
|
||||
// из N email по одному типу заново обходил бы SharePoint N раз подряд.
|
||||
var listsByType = new Dictionary<int, IReadOnlyList<RequestListPartitionDto>>();
|
||||
var items = new List<RequestTypeAccessDto>(entities.Count);
|
||||
foreach (var e in entities)
|
||||
{
|
||||
var lists = await GetListsCachedAsync(listsByType, e.RequestTypeId, cancellationToken);
|
||||
items.Add(new RequestTypeAccessDto(e.Id, e.RequestTypeId, e.Email, lists));
|
||||
}
|
||||
|
||||
return Result.Ok<IReadOnlyList<RequestTypeAccessDto>>(items);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<RequestListPartitionDto>> GetListsCachedAsync(
|
||||
Dictionary<int, IReadOnlyList<RequestListPartitionDto>> cache,
|
||||
int requestTypeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (cache.TryGetValue(requestTypeId, out var cached))
|
||||
return cached;
|
||||
|
||||
var result = await partitions.ListAsync(requestTypeId, cancellationToken);
|
||||
var lists = result.IsSuccess ? result.Value : Array.Empty<RequestListPartitionDto>();
|
||||
cache[requestTypeId] = lists;
|
||||
return lists;
|
||||
}
|
||||
|
||||
/// <summary>Список email'ов с доступом к типу заявок — дёргает Power Automate.</summary>
|
||||
public async Task<Result<IReadOnlyList<string>>> ListEmailsAsync(int requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == requestTypeId, cancellationToken))
|
||||
return Result.Fail<IReadOnlyList<string>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
var emails = await db.RequestTypeAccesses.AsNoTracking()
|
||||
.Where(x => x.RequestTypeId == requestTypeId)
|
||||
.OrderBy(x => x.Email)
|
||||
.Select(x => x.Email)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<string>>(emails);
|
||||
}
|
||||
|
||||
public async Task<Result<RequestTypeAccessDto>> CreateAsync(
|
||||
CreateRequestTypeAccessRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken))
|
||||
return Result.Fail<RequestTypeAccessDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||
|
||||
var email = request.Email.Trim();
|
||||
var exists = await db.RequestTypeAccesses.AsNoTracking()
|
||||
.AnyAsync(x => x.RequestTypeId == request.RequestTypeId && x.Email == email, cancellationToken);
|
||||
if (exists)
|
||||
return Result.Fail<RequestTypeAccessDto>(
|
||||
new ConflictError($"У {email} уже есть доступ к этому типу заявок."));
|
||||
|
||||
var entity = new RequestTypeAccess { RequestTypeId = request.RequestTypeId, Email = email };
|
||||
db.RequestTypeAccesses.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Доступ к типу заявки {RequestTypeId} выдан {Email} пользователем {Author}",
|
||||
entity.RequestTypeId, entity.Email, currentUser.Email);
|
||||
|
||||
var listsResult = await partitions.ListAsync(entity.RequestTypeId, cancellationToken);
|
||||
var lists = listsResult.IsSuccess ? listsResult.Value : Array.Empty<RequestListPartitionDto>();
|
||||
return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email, lists));
|
||||
}
|
||||
|
||||
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.RequestTypeAccesses.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail(new NotFoundError("Доступ к типу заявки", id));
|
||||
|
||||
db.RequestTypeAccesses.Remove(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Доступ к типу заявки {RequestTypeId} для {Email} отозван пользователем {Author}",
|
||||
entity.RequestTypeId, entity.Email, currentUser.Email);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||
|
||||
public sealed class CreateRequestTypeAccessRequestValidator : AbstractValidator<CreateRequestTypeAccessRequest>
|
||||
{
|
||||
public CreateRequestTypeAccessRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.RequestTypeId).GreaterThan(0).WithMessage("Тип заявки обязателен.");
|
||||
RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("Укажите корректный email.");
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Application.Common;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
@@ -82,7 +83,7 @@ public sealed class TemplateRouteStepService(
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
var entity = new TemplateRouteStep
|
||||
{
|
||||
@@ -141,7 +142,7 @@ public sealed class TemplateRouteStepService(
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
entity.OrderNo = r.OrderNo;
|
||||
entity.ExecutorEmail = r.ExecutorEmail;
|
||||
@@ -210,7 +211,7 @@ public sealed class TemplateRouteStepService(
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||
entity.ExecutorEmail = executorEmail;
|
||||
|
||||
@@ -3,6 +3,7 @@ using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.Common;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
@@ -437,7 +438,7 @@ public sealed class TemplateRouteService(
|
||||
{
|
||||
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
|
||||
warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
|
||||
}
|
||||
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
||||
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
||||
@@ -469,7 +470,14 @@ public sealed class TemplateRouteService(
|
||||
_ => null
|
||||
};
|
||||
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
|
||||
{
|
||||
@@ -612,7 +620,7 @@ public sealed class TemplateRouteService(
|
||||
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
||||
{
|
||||
var conflictWarning =
|
||||
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}.";
|
||||
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}».";
|
||||
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
result[index] = existing with { Warning = combinedWarning };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Право одного пользователя видеть ВСЕ заявки одного типа в портале заявок
|
||||
/// (а не только свои). У одного человека может быть по строке на каждый тип
|
||||
/// заявок, к которому ему открыт полный доступ.
|
||||
/// </summary>
|
||||
public class RequestTypeAccess
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int RequestTypeId { get; set; }
|
||||
public RequestType? RequestType { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||
public DbSet<RequestTypeAccess> RequestTypeAccesses => Set<RequestTypeAccess>();
|
||||
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
|
||||
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||
@@ -35,6 +36,15 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
||||
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RequestTypeAccess>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||
e.HasOne(x => x.RequestType).WithMany().HasForeignKey(x => x.RequestTypeId);
|
||||
// Одна и та же пара тип+email не должна выдаваться дважды.
|
||||
e.HasIndex(x => new { x.RequestTypeId, x.Email }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RequestListPartition>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||
[Migration("20260925092352_AddRequestTypeAccess")]
|
||||
partial class AddRequestTypeAccess
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("number");
|
||||
|
||||
b.Property<int>("RequestTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("request_type_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_request_list_partitions");
|
||||
|
||||
b.HasIndex("RequestTypeId", "Number")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
|
||||
|
||||
b.ToTable("request_list_partitions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool?>("AskOverdueReason")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("ask_overdue_reason");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("list_partitioning_started_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("PageUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("page_url");
|
||||
|
||||
b.Property<string>("Section")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("section");
|
||||
|
||||
b.Property<string>("SiteUrl")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("site_url");
|
||||
|
||||
b.Property<int?>("SlaHours")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sla_hours");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_request_types");
|
||||
|
||||
b.ToTable("request_types", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<int>("RequestTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("request_type_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_request_type_accesses");
|
||||
|
||||
b.HasIndex("RequestTypeId", "Email")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_request_type_accesses_request_type_id_email");
|
||||
|
||||
b.ToTable("request_type_accesses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("ByProject")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("by_project");
|
||||
|
||||
b.Property<Guid?>("CompanyId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("company_id");
|
||||
|
||||
b.Property<Guid?>("DepartmentId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("department_id");
|
||||
|
||||
b.Property<int>("RequestTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("request_type_id");
|
||||
|
||||
b.Property<bool>("RequiresControl")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("requires_control");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_template_routes");
|
||||
|
||||
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||
|
||||
b.ToTable("template_routes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("allow_reassign_to_other_approvers");
|
||||
|
||||
b.Property<bool?>("AskOverdueReason")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("ask_overdue_reason");
|
||||
|
||||
b.Property<bool>("AutoApprove")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("auto_approve");
|
||||
|
||||
b.Property<string>("ConditionsLogic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("conditions_logic");
|
||||
|
||||
b.Property<string>("ExecutorEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("executor_email");
|
||||
|
||||
b.Property<string>("HeadType")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("head_type");
|
||||
|
||||
b.Property<int>("OrderNo")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("order_no");
|
||||
|
||||
b.Property<string>("Position")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("position");
|
||||
|
||||
b.Property<string>("ProjectRole")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("project_role");
|
||||
|
||||
b.Property<int>("RouteId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("route_id");
|
||||
|
||||
b.Property<int?>("SlaHours")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sla_hours");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_template_route_steps");
|
||||
|
||||
b.HasIndex("RouteId", "OrderNo")
|
||||
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||
|
||||
b.ToTable("template_route_steps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("FieldName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("field_name");
|
||||
|
||||
b.Property<string>("Operation")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<int>("OrderNo")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("order_no");
|
||||
|
||||
b.Property<int>("StepId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("step_id");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_template_route_step_conditions");
|
||||
|
||||
b.HasIndex("StepId", "OrderNo")
|
||||
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
|
||||
|
||||
b.ToTable("template_route_step_conditions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("OptionName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("option_name");
|
||||
|
||||
b.Property<byte[]>("FileData")
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("file_data");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("OptionName")
|
||||
.HasName("pk_settings");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||
.WithMany("ListPartitions")
|
||||
.HasForeignKey("RequestTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
|
||||
|
||||
b.Navigation("RequestType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||
.WithMany()
|
||||
.HasForeignKey("RequestTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_request_type_accesses_request_types_request_type_id");
|
||||
|
||||
b.Navigation("RequestType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||
.WithMany("Routes")
|
||||
.HasForeignKey("RequestTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||
|
||||
b.Navigation("RequestType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||
.WithMany("Steps")
|
||||
.HasForeignKey("RouteId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||
|
||||
b.Navigation("Route");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
|
||||
.WithMany("Conditions")
|
||||
.HasForeignKey("StepId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
|
||||
|
||||
b.Navigation("Step");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||
{
|
||||
b.Navigation("ListPartitions");
|
||||
|
||||
b.Navigation("Routes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||
{
|
||||
b.Navigation("Conditions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRequestTypeAccess : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "request_type_accesses",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
request_type_id = table.Column<int>(type: "integer", nullable: false),
|
||||
email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_request_type_accesses", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_request_type_accesses_request_types_request_type_id",
|
||||
column: x => x.request_type_id,
|
||||
principalTable: "request_types",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_request_type_accesses_request_type_id_email",
|
||||
table: "request_type_accesses",
|
||||
columns: new[] { "request_type_id", "email" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "request_type_accesses");
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -119,6 +119,35 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("request_types", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<int>("RequestTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("request_type_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_request_type_accesses");
|
||||
|
||||
b.HasIndex("RequestTypeId", "Email")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_request_type_accesses_request_type_id_email");
|
||||
|
||||
b.ToTable("request_type_accesses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -309,6 +338,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("RequestType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||
.WithMany()
|
||||
.HasForeignKey("RequestTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_request_type_accesses_request_types_request_type_id");
|
||||
|
||||
b.Navigation("RequestType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||
|
||||
@@ -54,6 +54,23 @@ public interface ISharePointClient
|
||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Может ли пользователь (по email) открыть элемент списка: эффективные права
|
||||
/// человека на элемент с учётом разорванного наследования, флаг ViewListItems.
|
||||
/// Неизвестный SharePoint логин — не ошибка, а «прав нет».
|
||||
/// </summary>
|
||||
Task<bool> CanUserViewItemAsync(
|
||||
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
|
||||
/// элементы, а не только доступные пользователю. Возвращает строки результата
|
||||
/// как «имя managed property → значение» (RowLimit — не больше 500).
|
||||
/// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
|
||||
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
|
||||
@@ -220,6 +237,73 @@ public sealed class SharePointClient(
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
||||
cancellationToken);
|
||||
|
||||
public async Task<bool> CanUserViewItemAsync(
|
||||
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken)
|
||||
{
|
||||
// Claims-логин Entra ID в SharePoint Online. Передаём через параметр-псевдоним
|
||||
// @u: в пути функции «|» и «#» SharePoint не разбирает.
|
||||
var login = Uri.EscapeDataString($"i:0#.f|membership|{userEmail.Trim().ToLowerInvariant()}");
|
||||
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})" +
|
||||
$"/getUserEffectivePermissions(@u)?@u='{login}'";
|
||||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||||
|
||||
// BasePermissions приходит как {High, Low} — числа строками. ViewListItems — бит 0x1 в Low.
|
||||
return json.TryGetProperty("Low", out var low)
|
||||
&& ulong.TryParse(low.ValueKind == JsonValueKind.String ? low.GetString() : low.GetRawText(), out var bits)
|
||||
&& (bits & ViewListItemsMask) != 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
request = new
|
||||
{
|
||||
Querytext = queryText,
|
||||
RowLimit = 500,
|
||||
SelectProperties = selectProperties,
|
||||
TrimDuplicates = false
|
||||
}
|
||||
});
|
||||
|
||||
var json = await SendAsync(
|
||||
HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body,
|
||||
allowNotFound: false, cancellationToken);
|
||||
|
||||
var result = new List<IReadOnlyDictionary<string, string?>>();
|
||||
if (json is null
|
||||
|| !json.Value.TryGetProperty("PrimaryQueryResult", out var primary)
|
||||
|| !primary.TryGetProperty("RelevantResults", out var relevant)
|
||||
|| !relevant.TryGetProperty("Table", out var table)
|
||||
|| !table.TryGetProperty("Rows", out var rows))
|
||||
return result;
|
||||
|
||||
// nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба.
|
||||
if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped))
|
||||
rows = wrapped;
|
||||
if (rows.ValueKind != JsonValueKind.Array) return result;
|
||||
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
if (!row.TryGetProperty("Cells", out var cells)) continue;
|
||||
if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells))
|
||||
cells = wrappedCells;
|
||||
|
||||
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var cell in cells.EnumerateArray())
|
||||
{
|
||||
var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null;
|
||||
if (key is null) continue;
|
||||
dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
}
|
||||
|
||||
result.Add(dict);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task UpdateItemByListTitleAsync(
|
||||
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
|
||||
MergeAsync(
|
||||
@@ -366,6 +450,8 @@ public sealed class SharePointClient(
|
||||
}
|
||||
}
|
||||
|
||||
private const ulong ViewListItemsMask = 0x1;
|
||||
|
||||
// Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки
|
||||
// списков содержат тире и кириллицу, но кавычка теоретически возможна —
|
||||
// без экранирования она сломала бы запрос.
|
||||
|
||||
@@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
|
||||
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken 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) =>
|
||||
GetOrAddAsync(
|
||||
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
||||
|
||||
@@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient(
|
||||
|
||||
return employees
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.Fio))
|
||||
.Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty))
|
||||
.Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty, e.Position))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// /Dictionary/* отдаёт «голый» массив с ключами в PascalCase (Id/Name) —
|
||||
// PropertyNameCaseInsensitive в JsonOptions это покрывает.
|
||||
public async Task<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();
|
||||
}
|
||||
|
||||
@@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient(
|
||||
[property: JsonPropertyName("fio")] string? Fio,
|
||||
[property: JsonPropertyName("id")] Guid? Id = null);
|
||||
|
||||
private sealed record DictionaryItemResponse(
|
||||
[property: JsonPropertyName("id")] Guid? Id,
|
||||
[property: JsonPropertyName("name")] string? Name);
|
||||
|
||||
private sealed record ManagerResponse(
|
||||
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ public interface ITnsuPortalClient
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company,
|
||||
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
|
||||
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
|
||||
/// </summary>
|
||||
public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company);
|
||||
public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company, string? Position = null);
|
||||
|
||||
/// <summary>Компания из справочника /Dictionary/Companies.</summary>
|
||||
public sealed record CompanyInfo(Guid Id, string Name);
|
||||
|
||||
/// <summary>Отдел из справочника /Dictionary/Departments.</summary>
|
||||
public sealed record DepartmentInfo(Guid Id, string Name);
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Заявки по одному основанию: видны все (чтение от имени приложения), а
|
||||
/// CanOpen — только там, где у вызывающего есть права на элемент.
|
||||
/// </summary>
|
||||
public sealed class BasisRequestsServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||
{
|
||||
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||||
private const string CallerEmail = "initiator@tnsu.kz";
|
||||
private const string BasisKey = "need:8028c7ac-ac23-8dd8-11f0-946facc42464";
|
||||
|
||||
private readonly ServiceProvider _services;
|
||||
|
||||
public BasisRequestsServiceTests(PostgresDatabaseFixture fixture)
|
||||
{
|
||||
_services = TestServiceProviderFactory.Build(fixture.ConnectionString, CallerEmail);
|
||||
}
|
||||
|
||||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||
|
||||
private async Task<(BasisRequestsService Service, Mock<ISharePointClient> SharePoint, int TypeId)> BuildAsync(
|
||||
string name, string listUrl, IReadOnlyList<object> items, IReadOnlySet<int> openableIds)
|
||||
{
|
||||
var type = await _services.GetRequiredService<RequestTypeService>().CreateAsync(
|
||||
new CreateRequestTypeRequest($"Оплата {name}", $"/оплата-{name}", listUrl, SiteUrl, "General"),
|
||||
CancellationToken.None);
|
||||
|
||||
var sharePoint = new Mock<ISharePointClient>();
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items.Select(Json).ToList());
|
||||
sharePoint
|
||||
.Setup(x => x.CanUserViewItemAsync(SiteUrl, listUrl, It.IsAny<int>(), CallerEmail, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string _, string _, int id, string _, CancellationToken _) => openableIds.Contains(id));
|
||||
|
||||
var service = new BasisRequestsService(
|
||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||
sharePoint.Object,
|
||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||
new TestCurrentUser(CallerEmail));
|
||||
|
||||
return (service, sharePoint, type.Value.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_all_requests_and_marks_only_permitted_as_openable()
|
||||
{
|
||||
const string list = "/sites/requests/Lists/Оплата-all";
|
||||
var (service, sharePoint, typeId) = await BuildAsync("all", list,
|
||||
[
|
||||
// Number-колонка приходит дробной («50000.0») — тест держит её такой же.
|
||||
new { Id = 1, RequestUniqueCode = "24-2610-1", RequestStatus = "Согласовано", PayAmount = 50000.0 },
|
||||
new { Id = 2, RequestUniqueCode = "24-2610-2", RequestStatus = "На согласовании", PayAmount = 35000.5 }
|
||||
], new HashSet<int> { 2 });
|
||||
|
||||
var result = await service.GetByBasisAsync(typeId, BasisKey, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Value.Count.ShouldBe(2);
|
||||
result.Value.Single(x => x.Id == 1).ShouldBe(new BasisRequestDto(1, list, "24-2610-1", "Согласовано", 50000, false));
|
||||
result.Value.Single(x => x.Id == 2).ShouldBe(new BasisRequestDto(2, list, "24-2610-2", "На согласовании", 35000.5, true));
|
||||
|
||||
// Фильтр по основанию уходит в SharePoint, а не применяется в памяти.
|
||||
sharePoint.Verify(x => x.GetItemsByListUrlAsync(
|
||||
SiteUrl, list,
|
||||
It.Is<string>(q => q.Contains("BasisKey eq 'need%3A8028c7ac-ac23-8dd8-11f0-946facc42464'")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_basis_key_is_a_validation_error()
|
||||
{
|
||||
var (service, _, typeId) = await BuildAsync("empty", "/sites/requests/Lists/Оплата-empty", [], new HashSet<int>());
|
||||
|
||||
var result = await service.GetByBasisAsync(typeId, " ", CancellationToken.None);
|
||||
|
||||
result.IsFailed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -361,8 +361,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
|
||||
var warning = resolved.Value.Steps[0].Warning;
|
||||
warning.ShouldNotBeNull();
|
||||
warning.ShouldContain("Chief");
|
||||
warning.ShouldContain("DirectionHead");
|
||||
warning.ShouldContain("Непосредственный руководитель");
|
||||
warning.ShouldContain("Руководитель направления");
|
||||
warning.ShouldNotContain("DirectionHead");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
||||
@@ -1050,7 +1051,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||
var warning = resolved.Value.Steps[0].Warning;
|
||||
warning.ShouldNotBeNull();
|
||||
warning.ShouldContain("руководитель");
|
||||
warning.ShouldBe("Не найден непосредственный руководитель.");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
||||
@@ -1108,7 +1109,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||
var warning = resolved.Value.Steps[0].Warning;
|
||||
warning.ShouldNotBeNull();
|
||||
warning.ShouldContain("роль");
|
||||
warning.ShouldBe("В проекте не найден сотрудник службы безопасности.");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
||||
|
||||
+2
-2
@@ -75,8 +75,8 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
OpenVacanciesCount = vacancies,
|
||||
SubmitDate = submitDate,
|
||||
Created = created,
|
||||
OpenReason = openReason,
|
||||
OpenReasonDetails = openReasonDetails,
|
||||
VacancyOpenReason = openReason,
|
||||
VacancyOpenReasonDetails = openReasonDetails,
|
||||
Responsible = new { Title = responsible }
|
||||
};
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ import { texts } from '../../texts';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||
import { RequestTypeAccessScreen } from '../screens/RequestTypeAccessScreen';
|
||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
||||
import { RoutesExportScreen } from '../screens/RoutesExportScreen';
|
||||
|
||||
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
||||
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
||||
@@ -29,8 +31,10 @@ const NAV_ITEMS: ISidebarItem[] = [
|
||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||
{ id: 'routesExport', title: texts.nav.routesExport, icon: 'document' },
|
||||
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' }
|
||||
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' },
|
||||
{ id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' }
|
||||
];
|
||||
|
||||
function sidebarActiveId(tab: ScreenKey): string {
|
||||
@@ -147,6 +151,14 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
<div className={styles.content}>
|
||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||
{route.tab === 'requestTypeAccess' && (
|
||||
<RequestTypeAccessScreen
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
initialFilterRequestTypeId={route.accessFilterRequestTypeId}
|
||||
initialFilterEmail={route.accessFilterEmail}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routes' && !route.routeId && (
|
||||
<RoutesScreen
|
||||
api={api}
|
||||
@@ -174,6 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
route={route.resolve}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
|
||||
{route.tab === 'manageRequests' && (
|
||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||
)}
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser';
|
||||
export type ScreenKey =
|
||||
| 'dashboard'
|
||||
| 'requestTypes'
|
||||
| 'requestTypeAccess'
|
||||
| 'routes'
|
||||
| 'routeSteps'
|
||||
| 'resolve'
|
||||
| 'routesExport'
|
||||
| 'manageRequests'
|
||||
| 'manageByUser';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { RequestManageService } from '../../services/requestManageService';
|
||||
import { ApiClientError } from '../../services/httpClient';
|
||||
import { REQUEST_STATUS, RequestListPartition, UserApprovalRow, UserTemplateStepRow } from '../../models';
|
||||
import { REQUEST_STATUS, UserApprovalRow, UserTemplateStepRow } from '../../models';
|
||||
import { routeStepStatusLabels, texts } from '../../texts';
|
||||
import { shortGuid } from '../../utils/guid';
|
||||
import { describeStepCondition } from '../../utils/stepExecutor';
|
||||
@@ -15,7 +15,6 @@ import { mapWithLimit } from '../../utils/parallel';
|
||||
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
|
||||
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
|
||||
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
@@ -95,7 +94,6 @@ interface IReplaceScope {
|
||||
|
||||
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const listSeries = useRequestTypeListsAll(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const dictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
@@ -125,27 +123,21 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
||||
});
|
||||
}, [employeeEmail, targetEmail]);
|
||||
|
||||
const partitionsByType = React.useMemo(() => {
|
||||
const map = new Map<number, RequestListPartition[]>();
|
||||
listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
|
||||
return map;
|
||||
}, [listSeries.items]);
|
||||
|
||||
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
|
||||
const [liveLoading, setLiveLoading] = React.useState(false);
|
||||
const [liveError, setLiveError] = React.useState<string | undefined>();
|
||||
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const reloadLiveRows = React.useCallback(() => {
|
||||
if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return;
|
||||
if (!employeeEmail || requestTypes.isLoading) return;
|
||||
setLiveLoading(true);
|
||||
setLiveError(undefined);
|
||||
requestManage
|
||||
.findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES)
|
||||
api.requestManage
|
||||
.byApprover(employeeEmail, ACTIVE_STATUSES)
|
||||
.then(setLiveRows)
|
||||
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover')))
|
||||
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: byApprover')))
|
||||
.finally(() => setLiveLoading(false));
|
||||
}, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]);
|
||||
}, [api, requestTypes.isLoading, employeeEmail]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLiveRows([]);
|
||||
@@ -273,7 +265,11 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
|
||||
// Фаза 2: «чистые» строки — параллельно, друг от друга не зависят.
|
||||
const replaced = await mapWithLimit(
|
||||
clean,
|
||||
row => requestManage.reassignApprover(row, row.id, email, position),
|
||||
row =>
|
||||
requestManage.reassignApprover(row, row.id, email, position, {
|
||||
ticketId: row.ticketId,
|
||||
dataListId: row.listId
|
||||
}),
|
||||
{ onSettled: advanceProgress }
|
||||
);
|
||||
replaced.forEach(result => {
|
||||
|
||||
+6
-1
@@ -74,6 +74,11 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
||||
const excludedSet = new Set(excludedEmails.map(e => e.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 trimmedEmail = email.trim();
|
||||
if (!trimmedEmail) {
|
||||
@@ -95,7 +100,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}>
|
||||
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose} isDirty={isDirty}>
|
||||
<FormGrid>
|
||||
<Field label={texts.manageRequests.approverLabel} required full>
|
||||
<SearchableSelect
|
||||
|
||||
+13
-3
@@ -214,13 +214,23 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
const isBlockStart = block.start === i;
|
||||
const movable = blockIndex > lastLockedBlockIndex;
|
||||
const rowEditable = isEditableRowStatus(r.status);
|
||||
// Уведомлять можно только активный шаг (флоу шлёт письмо тому,
|
||||
// чей RowID мы передали, независимо от статуса — сам он не
|
||||
// Уведомлять можно активный шаг (флоу шлёт письмо тому, чей
|
||||
// RowID мы передали, независимо от статуса — сам он не
|
||||
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
|
||||
// слать по нему нечего: согласующий/исполнитель пока не
|
||||
// должен действовать (см. request-portal/notify.service.ts,
|
||||
// где по той же причине уведомляется только активный шаг).
|
||||
const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution;
|
||||
//
|
||||
// «Возвращена на доработку» — тоже уведомляемый статус, хотя
|
||||
// и не активный: для него тот же поток шлёт письмо не
|
||||
// согласующему строки, а АВТОРУ заявки (см.
|
||||
// RequestManageService.notifyRow) — так интерфейс
|
||||
// редактирования заявок получает кнопку «сообщить автору о
|
||||
// доработке».
|
||||
const notifiable =
|
||||
r.status === REQUEST_STATUS.onApproval ||
|
||||
r.status === REQUEST_STATUS.onExecution ||
|
||||
r.status === REQUEST_STATUS.returned;
|
||||
// Переключение параллельности — тоже перестановка (меняет order
|
||||
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
|
||||
// а не на rowEditable: у «Возвращена на доработку» кнопка
|
||||
|
||||
@@ -235,12 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
|
||||
const searchRequests = React.useCallback(
|
||||
(query: string, page: number) => {
|
||||
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
|
||||
if (!requestType || !requestType.siteUrl) {
|
||||
return Promise.resolve({ items: [], hasMore: false });
|
||||
}
|
||||
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
|
||||
return api.requestManage.search(requestType.id, query, page);
|
||||
},
|
||||
[requestManage, requestType, partitions.items]
|
||||
[api, requestType]
|
||||
);
|
||||
|
||||
// silent=true — фоновое обновление (после своего же действия, автообновление
|
||||
@@ -270,8 +270,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
}
|
||||
if (!silent) setMatrixLoading(true);
|
||||
setMatrixError(undefined);
|
||||
requestManage
|
||||
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
|
||||
api.requestManage
|
||||
.matrix(requestType.id, listId, selectedRequest.id)
|
||||
.then(m => {
|
||||
setMatrix(m);
|
||||
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
|
||||
@@ -296,12 +296,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
return same ? prev : { ...prev, selectedRequest: m.request };
|
||||
});
|
||||
})
|
||||
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
|
||||
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: matrix')))
|
||||
.finally(() => {
|
||||
if (!silent) setMatrixLoading(false);
|
||||
});
|
||||
},
|
||||
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
|
||||
[api, requestType, selectedRequest, partitions.items, partitions.isLoading]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -433,7 +433,10 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
// SharePoint, и параллельные update по одному элементу списка могут
|
||||
// затереть друг друга (последний выигрывает).
|
||||
return requestManage
|
||||
.reassignApprover(matrix, rowId, email, position)
|
||||
.reassignApprover(matrix, rowId, email, position, {
|
||||
ticketId: matrix.request.id,
|
||||
dataListId: matrix.request.listId
|
||||
})
|
||||
.then(() => requestManage.updateRowSla(matrix, rowId, slaHours, askOverdueReason))
|
||||
.then(() => {
|
||||
const found = findEmployeeByEmail(employees.items, email);
|
||||
@@ -507,7 +510,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
|
||||
|
||||
requestManage
|
||||
.addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false)
|
||||
.addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false, matrix.request.listId)
|
||||
.then(() => reloadMatrix(true))
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.filterField {
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chosen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chosenItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 4px 8px 4px 12px;
|
||||
border: 1px solid #e1e4ea;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chosenEmpty {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestTypeAccessScreen.module.scss';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useRequestTypeAccess } from '../../hooks/useRequestTypeAccess';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { replaceRoute } from '../../routing';
|
||||
import { RequestTypeAccess } from '../../models';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
FormModal,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
SearchableSelect,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
export interface IRequestTypeAccessScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
dictionary?: PortalDictionaryClient;
|
||||
/** Фильтры из ссылки (aaacctype/aaaccemail) — перекрывают сохранённые, если заданы. */
|
||||
initialFilterRequestTypeId?: string;
|
||||
initialFilterEmail?: string;
|
||||
}
|
||||
|
||||
// Фильтры переживают перезагрузку — тот же приём, что и ROUTES_FILTER_STORAGE_KEY в RoutesScreen.
|
||||
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
|
||||
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> = ({
|
||||
api,
|
||||
dictionary,
|
||||
initialFilterRequestTypeId,
|
||||
initialFilterEmail
|
||||
}) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const access = useRequestTypeAccess(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
|
||||
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
|
||||
const { requestTypeId, emails } = draft.value;
|
||||
const setEmails = (update: (current: string[]) => string[]): void =>
|
||||
draft.setValue(f => ({ ...f, emails: update(f.emails) }));
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
||||
const [filterEmail, setFilterEmail] = useLocalStorageState(ACCESS_FILTER_EMAIL_STORAGE_KEY, '');
|
||||
|
||||
// Ссылка с фильтром сильнее сохранённого значения — только на монтировании
|
||||
// (см. тот же приём в RoutesScreen).
|
||||
React.useEffect(() => {
|
||||
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (initialFilterEmail) setFilterEmail(initialFilterEmail);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Держим адрес синхронным с фильтрами — отфильтрованную таблицу можно
|
||||
// передать ссылкой. replaceRoute, а не navigate: смена фильтра не должна
|
||||
// плодить записи в истории браузера.
|
||||
React.useEffect(() => {
|
||||
replaceRoute({ tab: 'requestTypeAccess', accessFilterRequestTypeId: filterRequestTypeId, accessFilterEmail: filterEmail });
|
||||
}, [filterRequestTypeId, filterEmail]);
|
||||
|
||||
const deleteTarget = useDeleteTarget<RequestTypeAccess>(item => api.requestTypeAccess.remove(item.id), access.reload);
|
||||
|
||||
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||
|
||||
const filteredItems = access.items.filter(item => {
|
||||
if (filterRequestTypeId && String(item.requestTypeId) !== filterRequestTypeId) return false;
|
||||
if (filterEmail && item.email.toLowerCase() !== filterEmail.toLowerCase()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ
|
||||
// здесь настраивает только видимость в UI approval-route, но не в самом
|
||||
// SharePoint (поиск по порталу отфильтрует чужие заявки правами списка).
|
||||
const formatSharePointLists = (item: RequestTypeAccess): string =>
|
||||
item.sharePointLists.length === 0
|
||||
? texts.common.empty
|
||||
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
|
||||
|
||||
const openCreate = (): void => {
|
||||
createOpen.open();
|
||||
};
|
||||
|
||||
const submitForm = (): void => {
|
||||
if (!requestTypeId) {
|
||||
submit.fail(texts.requestTypeAccess.validationRequestType);
|
||||
return;
|
||||
}
|
||||
if (emails.length === 0) {
|
||||
submit.fail(texts.requestTypeAccess.validationEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
// По одному запросу на сотрудника (бэкенд выдаёт доступ по одному). Падение на
|
||||
// одном не отменяет остальных: неудавшиеся остаются в списке с причиной, чтобы
|
||||
// можно было поправить и нажать ещё раз.
|
||||
submit.run(async () => {
|
||||
const failures: string[] = [];
|
||||
const failedEmails: string[] = [];
|
||||
for (const email of emails) {
|
||||
try {
|
||||
await api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email });
|
||||
} catch (e) {
|
||||
failedEmails.push(email);
|
||||
failures.push(`${email}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedEmails.length < emails.length) access.reload();
|
||||
if (failures.length === 0) {
|
||||
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 (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypeAccess.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="people"
|
||||
subtitle={texts.requestTypeAccess.subtitle}
|
||||
title={texts.requestTypeAccess.title}
|
||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypeAccess.add}</Button>}
|
||||
/>
|
||||
|
||||
<div className={styles.filters}>
|
||||
<div className={styles.filterField}>
|
||||
<Field label={texts.requestTypeAccess.filterRequestTypeLabel}>
|
||||
<Select
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.requestTypeAccess.filterRequestTypeAll}
|
||||
value={filterRequestTypeId}
|
||||
onChange={e => setFilterRequestTypeId(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.filterField}>
|
||||
<Field label={texts.requestTypeAccess.filterEmailLabel}>
|
||||
<SearchableSelect
|
||||
options={employees.items.map(toEmployeeOption)}
|
||||
value={filterEmail}
|
||||
loading={employees.isLoading}
|
||||
allowClear
|
||||
clearLabel={texts.requestTypeAccess.filterEmailAll}
|
||||
placeholder={texts.requestTypeAccess.filterEmailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setFilterEmail}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{access.error && <Alert kind="err">{access.error}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'requestType', header: texts.requestTypeAccess.columnRequestType, render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> },
|
||||
{ key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email },
|
||||
{ key: 'sharePointLists', header: texts.requestTypeAccess.columnSharePointLists, render: formatSharePointLists },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={filteredItems}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={
|
||||
access.isLoading
|
||||
? texts.common.loading
|
||||
: access.items.length > 0 && filteredItems.length === 0
|
||||
? texts.requestTypeAccess.emptyFiltered
|
||||
: texts.requestTypeAccess.emptyList
|
||||
}
|
||||
/>
|
||||
|
||||
{createOpen.isOpen && (
|
||||
<FormModal
|
||||
title={texts.requestTypeAccess.createTitle}
|
||||
error={submit.error}
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={createOpen.close}
|
||||
onReset={draft.reset}
|
||||
canReset={draft.isDirty}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
|
||||
<Select
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
|
||||
value={requestTypeId}
|
||||
onChange={e => draft.setValue(f => ({ ...f, requestTypeId: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
||||
<div>
|
||||
<SearchableSelect
|
||||
options={employeeOptions}
|
||||
value=""
|
||||
loading={employees.isLoading}
|
||||
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={value => value && setEmails(current => [...current, value])}
|
||||
/>
|
||||
<div className={styles.chosen}>
|
||||
{emails.length === 0 && <span className={styles.chosenEmpty}>{texts.requestTypeAccess.noEmailsSelected}</span>}
|
||||
{emails.map(email => (
|
||||
<div key={email} className={styles.chosenItem}>
|
||||
<span>{employeeLabel(email)}</span>
|
||||
<IconButton
|
||||
icon="close"
|
||||
label={texts.requestTypeAccess.removeEmail}
|
||||
onClick={() => setEmails(current => current.filter(x => x !== email))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title={texts.requestTypeAccess.deleteTitle}
|
||||
text={texts.requestTypeAccess.deleteText(deleteTarget.target.email, requestTypeName(deleteTarget.target.requestTypeId))}
|
||||
confirmLabel={texts.common.remove}
|
||||
busy={deleteTarget.isBusy}
|
||||
onCancel={deleteTarget.cancel}
|
||||
onConfirm={deleteTarget.confirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,8 @@ import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
||||
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
@@ -57,6 +59,22 @@ const EMPTY_FORM: IFormState = {
|
||||
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-контракт бэкенда
|
||||
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
||||
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
||||
@@ -78,8 +96,18 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
const list = useRequestTypes(api);
|
||||
|
||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||
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 sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
||||
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
||||
@@ -92,21 +120,14 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setForm(EMPTY_FORM);
|
||||
setShowForm(true);
|
||||
setShowForm(false);
|
||||
createOpen.open();
|
||||
};
|
||||
|
||||
const openEdit = (item: RequestType): void => {
|
||||
setEditing(item);
|
||||
setForm({
|
||||
name: item.name,
|
||||
pageUrl: item.pageUrl,
|
||||
section: item.section,
|
||||
siteUrl: item.siteUrl ?? '',
|
||||
listId: item.listId ?? '',
|
||||
slaHours: slaHoursToInput(item.slaHours),
|
||||
askOverdueReason: boolToTriState(item.askOverdueReason)
|
||||
});
|
||||
setEditForm(toFormState(item));
|
||||
createOpen.close();
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
@@ -143,7 +164,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
|
||||
submit.run(() =>
|
||||
request.then(() => {
|
||||
setShowForm(false);
|
||||
if (!editing) draft.reset();
|
||||
closeForm();
|
||||
list.reload();
|
||||
})
|
||||
);
|
||||
@@ -192,13 +214,16 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
{(showForm || createOpen.isOpen) && (
|
||||
<FormModal
|
||||
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
||||
error={submit.error}
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
onClose={closeForm}
|
||||
onReset={editing ? undefined : draft.reset}
|
||||
canReset={draft.isDirty}
|
||||
isDirty={isEditDirty}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.requestTypes.nameLabel} required full>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { Company, Department, RequestType, TemplateRoute } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
||||
|
||||
@@ -27,6 +28,9 @@ interface IFormState {
|
||||
requiresControl: boolean;
|
||||
}
|
||||
|
||||
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
||||
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.route';
|
||||
|
||||
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
||||
if (!editing) {
|
||||
return {
|
||||
@@ -58,7 +62,10 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
||||
onSaved,
|
||||
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();
|
||||
|
||||
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
||||
@@ -93,6 +100,7 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
||||
|
||||
submit.run(() =>
|
||||
request.then(() => {
|
||||
draft.reset();
|
||||
onSaved();
|
||||
onClose();
|
||||
})
|
||||
@@ -106,6 +114,9 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={onClose}
|
||||
onReset={editing ? undefined : draft.reset}
|
||||
canReset={draft.isDirty}
|
||||
isDirty={Boolean(editing) && draft.isDirty}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.routes.requestTypeLabel} required full>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||||
@@ -119,6 +120,9 @@ const IS_MANAGER_VALUE_OPTIONS = [
|
||||
{ value: 'false', label: texts.common.no }
|
||||
];
|
||||
|
||||
/** Черновик формы создания шага — свой у каждого маршрута, переживает закрытие модалки (см. useFormDraft). */
|
||||
const createDraftStorageKey = (routeId: number): string => `approvalAdmin.draft.routeStep.${routeId}`;
|
||||
|
||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||
if (!editing) {
|
||||
return {
|
||||
@@ -170,7 +174,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
onSaved,
|
||||
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 hasListId = Boolean(requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||
@@ -400,6 +407,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
|
||||
submit.run(() =>
|
||||
request.then(step => {
|
||||
draft.reset();
|
||||
onSaved(step);
|
||||
onClose();
|
||||
})
|
||||
@@ -413,6 +421,9 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={onClose}
|
||||
onReset={editing ? undefined : draft.reset}
|
||||
canReset={draft.isDirty}
|
||||
isDirty={Boolean(editing) && draft.isDirty}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.routeSteps.orderNoLabel} required>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||
import {
|
||||
@@ -53,7 +54,14 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen),
|
||||
// ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal).
|
||||
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>(
|
||||
item => api.templateRouteSteps.remove(route.id, item.id),
|
||||
@@ -71,11 +79,13 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setShowForm(true);
|
||||
setShowForm(false);
|
||||
createOpen.open();
|
||||
};
|
||||
|
||||
const openEdit = (item: TemplateRouteStep): void => {
|
||||
setEditing(item);
|
||||
createOpen.close();
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
@@ -278,7 +288,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
{(showForm || createOpen.isOpen) && (
|
||||
<RouteStepFormModal
|
||||
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
||||
// применяется к любой компании инициатора) — тогда подсказка
|
||||
@@ -293,7 +303,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
onSaved={handleSaved}
|
||||
onClose={() => setShowForm(false)}
|
||||
onClose={closeForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
|
||||
|
||||
export interface IRoutesExportScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
}
|
||||
|
||||
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
|
||||
// делают по одному и тому же набору типов.
|
||||
const SELECTED_STORAGE_KEY = 'approvalAdmin.routesExport.selected';
|
||||
|
||||
function download(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
|
||||
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const allIds = requestTypes.items.map(rt => rt.id);
|
||||
// Удалённые с тех пор типы из сохранённого выбора не считаем.
|
||||
const selected = storedSelected.filter(id => allIds.indexOf(id) !== -1);
|
||||
const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1);
|
||||
|
||||
const toggle = (id: number, checked: boolean): void =>
|
||||
setSelected(current => (checked ? [...current.filter(x => x !== id), id] : current.filter(x => x !== id)));
|
||||
|
||||
const runExport = (): void => {
|
||||
setError(undefined);
|
||||
setIsBusy(true);
|
||||
api.reports.routes(selected)
|
||||
.then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName))
|
||||
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
|
||||
.then(() => setIsBusy(false), () => setIsBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="clipboard"
|
||||
subtitle={texts.routesExport.subtitle}
|
||||
title={texts.routesExport.title}
|
||||
actions={
|
||||
<Button
|
||||
variant="accent"
|
||||
icon="check"
|
||||
disabled={selected.length === 0 || isBusy}
|
||||
onClick={runExport}
|
||||
>
|
||||
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||
{error && <Alert kind="err">{error}</Alert>}
|
||||
|
||||
<Panel>
|
||||
<p>{texts.routesExport.hint}</p>
|
||||
{requestTypes.isLoading && <p>{texts.common.loading}</p>}
|
||||
{!requestTypes.isLoading && requestTypes.items.length === 0 && <p>{texts.routesExport.emptyTypes}</p>}
|
||||
{requestTypes.items.length > 0 && (
|
||||
<div>
|
||||
<Checkbox
|
||||
label={texts.routesExport.selectAll}
|
||||
checked={allSelected}
|
||||
onChange={checked => setSelected(checked ? allIds : [])}
|
||||
/>
|
||||
{requestTypes.items.map(rt => (
|
||||
<Checkbox
|
||||
key={rt.id}
|
||||
label={rt.name}
|
||||
checked={selected.indexOf(rt.id) !== -1}
|
||||
onChange={checked => toggle(rt.id, checked)}
|
||||
/>
|
||||
))}
|
||||
<p>{texts.routesExport.selected(selected.length)}</p>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { replaceRoute } from '../../routing';
|
||||
import {
|
||||
@@ -39,6 +40,8 @@ export interface IRoutesScreenProps {
|
||||
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
||||
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
||||
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
||||
/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */
|
||||
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route';
|
||||
|
||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
api,
|
||||
@@ -69,7 +72,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
||||
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 deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||
@@ -83,11 +92,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setShowForm(true);
|
||||
setShowForm(false);
|
||||
createOpen.open();
|
||||
};
|
||||
|
||||
const openEdit = (item: TemplateRoute): void => {
|
||||
setEditing(item);
|
||||
createOpen.close();
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
@@ -159,7 +170,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
{(showForm || createOpen.isOpen) && (
|
||||
<RouteFormModal
|
||||
api={api}
|
||||
editing={editing}
|
||||
@@ -169,7 +180,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
departments={portalDictionaries.departments}
|
||||
dictionaryLoading={portalDictionaries.isLoading}
|
||||
onSaved={routes.reload}
|
||||
onClose={() => setShowForm(false)}
|
||||
onClose={closeForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
gap: 16px;
|
||||
box-shadow: $sh-1;
|
||||
cursor: pointer;
|
||||
@include card-hover-gold;
|
||||
border: 1px solid $gray-200;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
// Миксин с вложенным &:hover — после всех объявлений, иначе Sass ругается mixed-decls.
|
||||
@include card-hover-gold;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Modal.module.scss';
|
||||
import { Button } from './Button';
|
||||
import { ModalOverlay } from './ModalOverlay';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
export interface IConfirmDialogProps {
|
||||
title: string;
|
||||
text: string;
|
||||
confirmLabel: string;
|
||||
/** Подпись кнопки отказа — по умолчанию «Отмена». */
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
busy?: boolean;
|
||||
@@ -16,18 +19,19 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
|
||||
title,
|
||||
text,
|
||||
confirmLabel,
|
||||
cancelLabel = texts.common.cancel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
busy
|
||||
}) => (
|
||||
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
||||
<ModalOverlay onBackdropClick={onCancel}>
|
||||
<div className={`${styles.box} ${styles.narrow}`}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<p className={styles.text}>{text}</p>
|
||||
<div className={styles.narrowFooter}>
|
||||
<Button variant="secondary" onClick={onCancel} disabled={busy}>{texts.common.cancel}</Button>
|
||||
<Button variant="secondary" onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
||||
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { Alert } from './Alert';
|
||||
import { Button } from './Button';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { Modal } from './Modal';
|
||||
import styles from './Modal.module.scss';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
export interface IFormModalProps {
|
||||
@@ -15,6 +17,18 @@ export interface IFormModalProps {
|
||||
savingLabel?: string;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается,
|
||||
* только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают.
|
||||
*/
|
||||
onReset?: () => void;
|
||||
canReset?: boolean;
|
||||
/**
|
||||
* В форме есть несохранённые правки существующей записи: выход (крестик,
|
||||
* «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания
|
||||
* не нужен — их черновик сохраняется сам (см. useFormDraft).
|
||||
*/
|
||||
isDirty?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -30,14 +44,32 @@ export const FormModal: React.FC<IFormModalProps> = ({
|
||||
savingLabel = texts.common.saving,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onReset,
|
||||
canReset,
|
||||
isDirty,
|
||||
children
|
||||
}) => (
|
||||
}) => {
|
||||
const [confirmingClose, setConfirmingClose] = React.useState(false);
|
||||
// Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл.
|
||||
const requestClose = (): void => {
|
||||
if (isSaving) return;
|
||||
if (isDirty) setConfirmingClose(true);
|
||||
else onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onClose={requestClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
|
||||
{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>
|
||||
@@ -47,4 +79,16 @@ export const FormModal: React.FC<IFormModalProps> = ({
|
||||
{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,9 +7,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 300;
|
||||
// Выше шапки SharePoint, но ниже слоёв Fluent UI (1000000). Оверлей рендерится
|
||||
// порталом в body (см. ModalOverlay), поэтому сравнивается с шапкой напрямую.
|
||||
z-index: 100000;
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 20px;
|
||||
// Портал вне корня веб-части — шрифт и метрики .shell (AdminApp.module.scss)
|
||||
// сюда не наследуются, задаём заново.
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
line-height: 1.55;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.box {
|
||||
@@ -63,4 +71,7 @@
|
||||
padding: 16px 26px 22px;
|
||||
}
|
||||
|
||||
/** Кнопка в подвале, прижатая влево, подальше от «Отмена/Сохранить» (см. «Очистить» в FormModal). */
|
||||
.footerStart { margin-right: auto; }
|
||||
|
||||
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Modal.module.scss';
|
||||
import { IconButton } from './IconButton';
|
||||
import { ModalOverlay } from './ModalOverlay';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
export interface IModalProps {
|
||||
@@ -11,8 +12,7 @@ export interface IModalProps {
|
||||
}
|
||||
|
||||
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
||||
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
|
||||
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<ModalOverlay onBackdropClick={onClose}>
|
||||
<div className={styles.box}>
|
||||
<div className={styles.header}>
|
||||
<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>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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';
|
||||
|
||||
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может
|
||||
// Ниже Modal.module.scss/.overlay (z-index 100000) — диалоги, которые может
|
||||
// открыть сама длительная операция (например, разрешение конфликта), должны
|
||||
// быть ПОВЕРХ этой подложки, а не под ней.
|
||||
.overlay {
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
box-shadow: $sh-modal;
|
||||
border: 1px solid $gray-200;
|
||||
padding: 6px;
|
||||
z-index: 400;
|
||||
z-index: 100001; // над оверлеем модалки (Modal.module.scss)
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -116,6 +116,7 @@
|
||||
|
||||
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
||||
&.selected { font-weight: 600; color: $navy-900; }
|
||||
&:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; }
|
||||
}
|
||||
|
||||
.itemSub {
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface ISearchableOption {
|
||||
label: string;
|
||||
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||
sublabel?: string;
|
||||
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ISearchableSelectProps {
|
||||
@@ -135,6 +137,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||
type="button"
|
||||
key={opt.value}
|
||||
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||
disabled={opt.disabled}
|
||||
onClick={() => pick(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from './PrimaryCell';
|
||||
export * from './ActionsCell';
|
||||
export * from './CellNote';
|
||||
export * from './Modal';
|
||||
export * from './ModalOverlay';
|
||||
export * from './ConfirmDialog';
|
||||
export * from './FormModal';
|
||||
export * from './FormGrid';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -5,27 +5,55 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
||||
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||
* квота) не ломает форму — просто не персистит.
|
||||
*
|
||||
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
|
||||
* обновления в форму добавили новое поле, у пользователей со старой записью в
|
||||
* localStorage оно не потеряется как undefined (что уронило бы рендер на
|
||||
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
|
||||
* Сохранённое значение, приведённое к форме initial. Запись могла быть сделана
|
||||
* старой версией формы, поэтому ей нельзя доверять слепо:
|
||||
* - объект мёржится с initial — добавленные с тех пор поля получают дефолт;
|
||||
* - ключи, которых в initial нет (поле удалили), отбрасываются;
|
||||
* - значение, чей тип не совпал с initial (было число — стала строка, было
|
||||
* [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер;
|
||||
* - null/undefined в initial типа не задают — там принимаем любое сохранённое.
|
||||
*/
|
||||
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const [value, setValue] = React.useState<T>(() => {
|
||||
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;
|
||||
|
||||
const parsed = JSON.parse(raw) as T;
|
||||
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
|
||||
return coerceStored(initial, JSON.parse(raw));
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
||||
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||
* квота) не ломает форму — просто не персистит.
|
||||
*
|
||||
* Сохранённое значение приводится к форме initial (см. coerceStored): новое поле
|
||||
* получает дефолт, удалённое отбрасывается, значение не того типа заменяется
|
||||
* дефолтом — иначе старая запись в localStorage уронила бы рендер на первом же
|
||||
* .map/.trim по этому полю.
|
||||
*/
|
||||
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const [value, setValue] = React.useState<T>(() => readLocalStorage(key, initial));
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { RequestTypeAccess } from '../models';
|
||||
import { ApprovalRouteApi } from '../services/api';
|
||||
import { AsyncList, useAsyncList } from './useAsyncList';
|
||||
|
||||
/** Реестр доступа «видеть все заявки типа» — целиком, по всем типам заявок. */
|
||||
export function useRequestTypeAccess(api: ApprovalRouteApi): AsyncList<RequestTypeAccess> {
|
||||
const load = React.useCallback(() => api.requestTypeAccess.list(), [api]);
|
||||
return useAsyncList(load);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
export * from './enums';
|
||||
export * from './dictionaries';
|
||||
export * from './requestTypes';
|
||||
export * from './requestTypeAccess';
|
||||
export * from './requestTypeLists';
|
||||
export * from './templateRoutes';
|
||||
export * from './templateRouteSteps';
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface UserApprovalRow extends ManagedApprovalRow {
|
||||
requestUniqueCode: string | null;
|
||||
}
|
||||
|
||||
/** Форма, в которой UserApprovalRow отдаёт Tnsu.ApprovalRoute.Api — см. requestManage.ts/ManagedApprovalRowApi. */
|
||||
export type UserApprovalRowApi = Omit<UserApprovalRow, 'date'>;
|
||||
|
||||
/** Шаг шаблона маршрута с ExecutorEmail = искомый человек, обогащённый для отображения. */
|
||||
export interface UserTemplateStepRow extends TemplateRouteStep {
|
||||
requestTypeName: string;
|
||||
|
||||
@@ -154,3 +154,17 @@ export interface ManagedRequestMatrix {
|
||||
/** Обе фазы вместе, упорядочены по StepOrder. */
|
||||
rows: ManagedApprovalRow[];
|
||||
}
|
||||
|
||||
// Формы, в которых те же данные отдаёт Tnsu.ApprovalRoute.Api
|
||||
// (backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageDtos.cs) —
|
||||
// сервис читает списки SharePoint от имени ПРИЛОЖЕНИЯ, а не текущего
|
||||
// пользователя (см. services/api.ts/requestManage), поэтому видимость заявок не
|
||||
// зависит от прав администратора на списки. Отличаются от ManagedApprovalRow/
|
||||
// ManagedRequestMatrix только одним полем: `date` (отформатированная
|
||||
// «дд.мм.гггг») здесь нет — она вычисляется на фронтенде из `dateIso` (см.
|
||||
// services/api.ts/toApprovalRow), бэкенду незачем знать про формат отображения.
|
||||
export type ManagedApprovalRowApi = Omit<ManagedApprovalRow, 'date'>;
|
||||
|
||||
export interface ManagedRequestMatrixApi extends Omit<ManagedRequestMatrix, 'rows'> {
|
||||
rows: ManagedApprovalRowApi[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// DTO эндпоинта api/request-type-access (Application/RequestTypeAccesses/*.cs).
|
||||
import { RequestListPartition } from './requestTypeLists';
|
||||
|
||||
export interface RequestTypeAccess {
|
||||
id: number;
|
||||
requestTypeId: number;
|
||||
email: string;
|
||||
/**
|
||||
* Вся серия SharePoint-списков данных типа заявки (архив + текущая партиция) —
|
||||
* именно на них нужно вручную выдать email'у право чтения, иначе поиск по
|
||||
* порталу продолжит отдавать только его собственные заявки: этот реестр
|
||||
* настраивает видимость в UI approval-route, а не права SharePoint.
|
||||
*/
|
||||
sharePointLists: RequestListPartition[];
|
||||
}
|
||||
|
||||
export interface CreateRequestTypeAccessInput {
|
||||
requestTypeId: number;
|
||||
email: string;
|
||||
}
|
||||
@@ -69,6 +69,14 @@ export interface IAdminRoute {
|
||||
* к тому же отфильтрованному списку, а не сбросить фильтр.
|
||||
*/
|
||||
routesFilter?: string;
|
||||
/**
|
||||
* Фильтры таблицы «Доступ к заявкам» (id типа как строка, email) — только при
|
||||
* tab='requestTypeAccess'. Та же логика, что и у routesFilter: живут в адресе,
|
||||
* чтобы отфильтрованную ссылку можно было передать и чтобы фильтр не терялся
|
||||
* при возврате на вкладку.
|
||||
*/
|
||||
accessFilterRequestTypeId?: string;
|
||||
accessFilterEmail?: string;
|
||||
/** Задан только при tab='resolve' и непустом requestTypeId. */
|
||||
resolve?: IResolveRouteParams;
|
||||
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
||||
@@ -81,10 +89,11 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
||||
|
||||
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
||||
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
||||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
|
||||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'routesExport', 'manageRequests', 'manageByUser', 'requestTypeAccess'];
|
||||
|
||||
const PARAM_KEYS = [
|
||||
'aatab', 'aaroute', 'aarfilter',
|
||||
'aaacctype', 'aaaccemail',
|
||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||
'aamtype', 'aamreq', 'aamlist',
|
||||
'aabuser', 'aabtarget'
|
||||
@@ -146,6 +155,8 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
||||
tab,
|
||||
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
||||
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
||||
accessFilterRequestTypeId: (tab === 'requestTypeAccess' ? params.get('aaacctype') : null) || undefined,
|
||||
accessFilterEmail: (tab === 'requestTypeAccess' ? params.get('aaaccemail') : null) || undefined,
|
||||
resolve,
|
||||
manage,
|
||||
manageByUser: byUserEmail
|
||||
@@ -167,6 +178,11 @@ function buildQuery(route: IAdminRoute): string {
|
||||
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
|
||||
}
|
||||
|
||||
if (route.tab === 'requestTypeAccess') {
|
||||
if (route.accessFilterRequestTypeId) params.set('aaacctype', route.accessFilterRequestTypeId);
|
||||
if (route.accessFilterEmail) params.set('aaaccemail', route.accessFilterEmail);
|
||||
}
|
||||
|
||||
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
|
||||
params.set('aartype', route.resolve.requestTypeId);
|
||||
if (route.resolve.userEmail) params.set('aauser', route.resolve.userEmail);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { IHttpClient } from './httpClient';
|
||||
import { IDownloadedFile, IHttpClient } from './httpClient';
|
||||
import {
|
||||
CreateRequestTypeAccessInput,
|
||||
CreateRequestTypeInput,
|
||||
CreateTemplateRouteInput,
|
||||
CreateTemplateRouteStepInput,
|
||||
ManagedApprovalRow,
|
||||
ManagedApprovalRowApi,
|
||||
ManagedRequestMatrix,
|
||||
ManagedRequestMatrixApi,
|
||||
ManagedRequestPage,
|
||||
PatchRequestTypeInput,
|
||||
PatchTemplateRouteInput,
|
||||
PatchTemplateRouteStepInput,
|
||||
RequestListPartition,
|
||||
RequestType,
|
||||
RequestTypeAccess,
|
||||
RequestTypeListSeries,
|
||||
ResolveTemplateRouteQuery,
|
||||
ResolveTemplateRouteResult,
|
||||
@@ -15,8 +22,26 @@ import {
|
||||
TemplateRouteStep,
|
||||
UpdateRequestTypeInput,
|
||||
UpdateTemplateRouteInput,
|
||||
UpdateTemplateRouteStepInput
|
||||
UpdateTemplateRouteStepInput,
|
||||
UserApprovalRow,
|
||||
UserApprovalRowApi
|
||||
} from '../models';
|
||||
import { formatDateIso } from '../utils/date';
|
||||
|
||||
// Api-формы (dateIso, без отформатированного date) → формы, которыми пользуются
|
||||
// экраны (ManageRequestsScreen/ManageByUserScreen) — см. requestManage.ts/
|
||||
// ManagedApprovalRowApi. Единственная разница — производное поле date.
|
||||
function toApprovalRow(r: ManagedApprovalRowApi): ManagedApprovalRow {
|
||||
return { ...r, date: formatDateIso(r.dateIso) };
|
||||
}
|
||||
|
||||
function toMatrix(m: ManagedRequestMatrixApi): ManagedRequestMatrix {
|
||||
return { ...m, rows: m.rows.map(toApprovalRow) };
|
||||
}
|
||||
|
||||
function toUserApprovalRow(r: UserApprovalRowApi): UserApprovalRow {
|
||||
return { ...r, date: formatDateIso(r.dateIso) };
|
||||
}
|
||||
|
||||
// Тонкий 1:1 фасад над контроллерами бэкенда:
|
||||
// backend/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
|
||||
@@ -47,8 +72,8 @@ export class ApprovalRouteApi {
|
||||
// ListPartitionService.ListAllAsync) — для дашборда (счётчик партиций) и
|
||||
// портала, которому нужен весь список адресов разом, а не по одному запросу
|
||||
// на тип.
|
||||
listsAll: (): Promise<RequestTypeListSeries[]> =>
|
||||
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists'),
|
||||
listsAll: (title?: string): Promise<RequestTypeListSeries[]> =>
|
||||
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists', { title }),
|
||||
|
||||
// Вся серия партиций ОДНОГО типа заявки (архив + текущая) — нужна экрану
|
||||
// редактирования заявок: заявка может лежать в любой из них, не только в
|
||||
@@ -57,6 +82,21 @@ export class ApprovalRouteApi {
|
||||
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
|
||||
};
|
||||
|
||||
// Доступ «видеть все заявки типа» (Application/RequestTypeAccesses/*.cs) —
|
||||
// тот же список email по типу, что и ручка для Power Automate
|
||||
// (GET .../{requestTypeId}/emails), только здесь читается ВЕСЬ реестр разом
|
||||
// (requestTypeId не передан) — админке нужна одна таблица по всем типам.
|
||||
public readonly requestTypeAccess = {
|
||||
list: (requestTypeId?: number): Promise<RequestTypeAccess[]> =>
|
||||
this.http.get<RequestTypeAccess[]>('/api/request-type-access', { requestTypeId }),
|
||||
|
||||
create: (body: CreateRequestTypeAccessInput): Promise<RequestTypeAccess> =>
|
||||
this.http.post<RequestTypeAccess>('/api/request-type-access', body),
|
||||
|
||||
remove: (id: number): Promise<void> =>
|
||||
this.http.delete(`/api/request-type-access/${id}`)
|
||||
};
|
||||
|
||||
public readonly templateRoutes = {
|
||||
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
|
||||
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
|
||||
@@ -107,4 +147,35 @@ export class ApprovalRouteApi {
|
||||
listByExecutorEmail: (executorEmail: string): Promise<TemplateRouteStep[]> =>
|
||||
this.http.get<TemplateRouteStep[]>('/api/template-route-steps', { executorEmail })
|
||||
};
|
||||
|
||||
// Чтение заявок и маршрутов согласования от имени ПРИЛОЖЕНИЯ (см.
|
||||
// backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs) —
|
||||
// в отличие от остальных групп выше, это НЕ CRUD над сущностями самого API:
|
||||
// данные по-прежнему живут в списках SharePoint, которыми управляет
|
||||
// request-portal (см. requestManageService.ts), только читаются они теперь
|
||||
// не PnP-клиентом текущего пользователя, а этим бэкендом, поэтому видимость
|
||||
// заявок не зависит от прав администратора на списки SharePoint.
|
||||
public readonly requestManage = {
|
||||
search: (requestTypeId: number, query: string, page: number, pageSize = 20): Promise<ManagedRequestPage> =>
|
||||
this.http.get<ManagedRequestPage>('/api/request-manage/search', { requestTypeId, query, page, pageSize }),
|
||||
|
||||
matrix: (requestTypeId: number, listId: string, requestId: number): Promise<ManagedRequestMatrix> =>
|
||||
this.http
|
||||
.get<ManagedRequestMatrixApi>('/api/request-manage/matrix', { requestTypeId, listId, requestId })
|
||||
.then(toMatrix),
|
||||
|
||||
byApprover: (approverEmail: string, activeStatuses: string[]): Promise<UserApprovalRow[]> =>
|
||||
this.http
|
||||
.get<UserApprovalRowApi[]>('/api/request-manage/by-approver', {
|
||||
approverEmail,
|
||||
statuses: activeStatuses.join(',')
|
||||
})
|
||||
.then(rows => rows.map(toUserApprovalRow))
|
||||
};
|
||||
|
||||
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
|
||||
public readonly reports = {
|
||||
routes: (requestTypeIds: number[]): Promise<IDownloadedFile> =>
|
||||
this.http.postForBlob('/api/reports/routes', { requestTypeIds })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,28 @@ export class ApiClientError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачанный файл; fileName пуст, если сервер не прислал Content-Disposition. */
|
||||
export interface IDownloadedFile {
|
||||
blob: Blob;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// filename*=UTF-8''... (RFC 5987, так отдаёт ASP.NET для кириллицы) или обычный filename=...
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- header — результат Headers.get() (DOM API)
|
||||
function fileNameFromDisposition(header: string | null | undefined): string | undefined {
|
||||
if (!header) return undefined;
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||||
if (encoded) {
|
||||
try {
|
||||
return decodeURIComponent(encoded[1]);
|
||||
} catch {
|
||||
// Битая кодировка — падаем на обычный filename ниже.
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";]+)"?/i.exec(header);
|
||||
return plain ? plain[1] : undefined;
|
||||
}
|
||||
|
||||
export type QueryParams = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
export interface IHttpClient {
|
||||
@@ -26,6 +48,8 @@ export interface IHttpClient {
|
||||
put<T>(path: string, body?: unknown): Promise<T>;
|
||||
patch<T>(path: string, body?: unknown): Promise<T>;
|
||||
delete(path: string): Promise<void>;
|
||||
/** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */
|
||||
postForBlob(path: string, body: unknown): Promise<IDownloadedFile>;
|
||||
}
|
||||
|
||||
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
||||
@@ -105,6 +129,21 @@ export class AadApiHttpClient implements IHttpClient {
|
||||
await this.send<void>('DELETE', path);
|
||||
}
|
||||
|
||||
public async postForBlob(path: string, body: unknown): Promise<IDownloadedFile> {
|
||||
const url = buildUrl(this.baseUrl, path);
|
||||
const response = await this.client.fetch(url, AadHttpClient.configurations.v1, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Ошибка приходит обычным JSON-конвертом ({ messages } или { errors }) — разбираем тем же парсером.
|
||||
await parseResponse<void>(response);
|
||||
throw new ApiClientError(response.status, [`HTTP ${response.status}`]);
|
||||
}
|
||||
return { blob: await response.blob(), fileName: fileNameFromDisposition(response.headers.get('Content-Disposition')) };
|
||||
}
|
||||
|
||||
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = buildUrl(this.baseUrl, path);
|
||||
return this.client
|
||||
|
||||
@@ -3,29 +3,9 @@ import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import '@pnp/sp/search';
|
||||
// sp.batched() — findActiveApprovalRowsForApprover читает все партиции ОДНОГО
|
||||
// сайта одним HTTP-запросом (см. метод ниже). Импорт side-effect'ный: без него
|
||||
// метод есть в типах, но в рантайме не зарегистрирован.
|
||||
import '@pnp/sp/batching';
|
||||
import { AadHttpClient } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
APPROVAL_PHASE,
|
||||
ManagedApprovalRow,
|
||||
ManagedRequestMatrix,
|
||||
ManagedRequestPage,
|
||||
ManagedRequestSummary,
|
||||
RequestListPartition,
|
||||
RequestType,
|
||||
UserApprovalRow
|
||||
} from '../models';
|
||||
|
||||
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
||||
// (конвенция имён; см. request-portal/src/webparts/requestPortal/forms/*/provisioning.json
|
||||
// и forms/*/approval/provisioning.json — этот сервис читает и пишет ровно те же
|
||||
// списки SharePoint, только с другого клиента).
|
||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
import { ManagedRequestMatrix, UserApprovalRow } from '../models';
|
||||
|
||||
// Тот же поток оповещения согласующего/исполнителя, что и в request-portal
|
||||
// (src/webparts/requestPortal/shared/config.ts) — один и тот же список
|
||||
@@ -42,79 +22,26 @@ const FLOW_NOTIFY_URL =
|
||||
// буквально — здесь их не выбирают заново, а просто дублируют.
|
||||
const FIELDS = {
|
||||
approver: 'TansuSiteApprover',
|
||||
status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки (см. IRawDataRow.RequestStatus ниже)
|
||||
status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки
|
||||
ticket: 'Ticket',
|
||||
date: 'Date',
|
||||
comment: 'Comment',
|
||||
order: 'StepOrder',
|
||||
allowReassign: 'AllowReassignToOtherApprovers',
|
||||
phase: 'Phase',
|
||||
// Колонки SLA заводятся провижинингом в request-portal вместе с остальными.
|
||||
slaHours: 'SLAhours',
|
||||
askOverdueReason: 'AskOverdueReason'
|
||||
order: 'StepOrder'
|
||||
};
|
||||
|
||||
function siteOrigin(siteUrl: string): string {
|
||||
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
||||
return match ? match[0] : siteUrl;
|
||||
}
|
||||
|
||||
function listAbsUrl(siteUrl: string, listId: string): string {
|
||||
return `${siteOrigin(siteUrl)}${listId}`;
|
||||
}
|
||||
|
||||
// KQL не переносит большинство спецсимволов в свободном терме — оставляем буквы
|
||||
// (лат./кир.), цифры и пробелы. Та же логика, что в request-portal
|
||||
// (features/myRequests/myRequests.service.ts/sanitizeForKql).
|
||||
function sanitizeForKql(text: string): string {
|
||||
return text.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function freeTextClause(query: string): string | undefined {
|
||||
const q = sanitizeForKql(query);
|
||||
if (!q) return undefined;
|
||||
return `(${q.split(' ').map(w => `${w}*`).join(' AND ')})`;
|
||||
}
|
||||
|
||||
function formatDateIso(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
interface IRawDataRow {
|
||||
Id: number;
|
||||
Title?: string;
|
||||
/** Статус ЗАЯВКИ (список «— данные») — не путать с FIELDS.status (статус строки маршрута). */
|
||||
RequestStatus?: string;
|
||||
Created?: string;
|
||||
Author?: { Title?: string };
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках
|
||||
RequestUniqueCode?: string | null;
|
||||
}
|
||||
|
||||
function toSummary(r: IRawDataRow, listId: string): ManagedRequestSummary {
|
||||
return {
|
||||
id: r.Id,
|
||||
title: r.Title || `#${r.Id}`,
|
||||
status: r.RequestStatus || '',
|
||||
created: r.Created || '',
|
||||
authorName: r.Author?.Title || '',
|
||||
uniqueCode: r.RequestUniqueCode || null,
|
||||
listId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint —
|
||||
* в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api),
|
||||
* данные заявок и строки маршрута живут только в списках SharePoint, которыми
|
||||
* управляет request-portal. У бэкенда этого API нет и не будет: типы заявок
|
||||
* знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков
|
||||
* ему не принадлежит.
|
||||
* Правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint, от имени
|
||||
* ТЕКУЩЕГО пользователя — в отличие от остального этого веб-парта (который
|
||||
* говорит с Tnsu.ApprovalRoute.Api), данные заявок и строки маршрута живут
|
||||
* только в списках SharePoint, которыми управляет request-portal.
|
||||
*
|
||||
* Чтение заявок (поиск, матрица, строки по согласующему) сюда больше не входит —
|
||||
* оно переехало на бэкенд (см. services/api.ts/requestManage,
|
||||
* backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs):
|
||||
* PnP-клиент читал списки от имени залогиненного администратора, и видимость
|
||||
* заявок молча урезалась его собственными правами на списки SharePoint —
|
||||
* бэкенд же читает от имени приложения. Правка (переназначить/удалить/
|
||||
* переставить/уведомить) по-прежнему требует прав самого пользователя на
|
||||
* запись в эти списки, поэтому остаётся здесь.
|
||||
*/
|
||||
export class RequestManageService {
|
||||
constructor(private readonly context: WebPartContext) {}
|
||||
@@ -123,202 +50,6 @@ export class RequestManageService {
|
||||
return spfi(siteUrl).using(SPFx(this.context));
|
||||
}
|
||||
|
||||
// Title списка данных → Title списка согласования. undefined, если Title не
|
||||
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
|
||||
// управлять маршрутом для него отсюда нельзя).
|
||||
private async listTitles(sp: SPFI, listId: string): Promise<{ dataListTitle: string; approvalListTitle: string | undefined }> {
|
||||
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
|
||||
const approvalListTitle = DATA_LIST_SUFFIX.test(info.Title)
|
||||
? `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`
|
||||
: undefined;
|
||||
return { dataListTitle: info.Title, approvalListTitle };
|
||||
}
|
||||
|
||||
// К какой партиции относится хит Search — по префиксу его Path. Партиции
|
||||
// определяются вызывающей стороной (ListPartitionService.ListAsync), их
|
||||
// список короткий (единицы), линейный поиск тут не проблема.
|
||||
private partitionForPath(
|
||||
siteUrl: string,
|
||||
partitions: RequestListPartition[],
|
||||
path: string
|
||||
): RequestListPartition | undefined {
|
||||
return partitions.filter(p => path.indexOf(listAbsUrl(siteUrl, p.listId)) === 0)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая)
|
||||
* постранично, для комбобокса выбора заявки. Сначала Search по названию
|
||||
* (свободный терм, пустой query — просто последние без фильтра), затем
|
||||
* дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
|
||||
* поля списка надёжно (та же двухшаговая схема, что в request-portal).
|
||||
*
|
||||
* Заявки, набранные ростом одного типа, физически «переезжают» на новый
|
||||
* список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
|
||||
* заявки из архивных партиций в комбобоксе просто не найти.
|
||||
*/
|
||||
public async searchRequests(
|
||||
partitions: RequestListPartition[],
|
||||
siteUrl: string,
|
||||
query: string,
|
||||
page: number,
|
||||
pageSize = 20
|
||||
): Promise<ManagedRequestPage> {
|
||||
if (partitions.length === 0) return { items: [], hasMore: false };
|
||||
|
||||
const sp = this.clientFor(siteUrl);
|
||||
const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`);
|
||||
const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0];
|
||||
const clauses = [pathClause];
|
||||
const text = freeTextClause(query);
|
||||
if (text) clauses.push(text);
|
||||
|
||||
const result = await sp.search({
|
||||
Querytext: clauses.join(' AND '),
|
||||
SelectProperties: ['ListItemID', 'Path'],
|
||||
RowLimit: pageSize,
|
||||
StartRow: (page - 1) * pageSize,
|
||||
TrimDuplicates: false,
|
||||
SortList: [{ Property: 'Created', Direction: 1 }]
|
||||
});
|
||||
|
||||
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>;
|
||||
// Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных
|
||||
// партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому
|
||||
// одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates:
|
||||
// false выше выключает штатную дедупликацию Search (она иногда схлопывает
|
||||
// нужные нам заявки), и тогда индекс может отдать один и тот же элемент
|
||||
// несколькими хитами (переиндексация, несколько «версий» в crawl) — та же
|
||||
// причина и то же лечение, что в request-portal
|
||||
// (features/myRequests/myRequests.service.ts/hitsToEntries).
|
||||
const seen = new Set<string>();
|
||||
const hits: Array<{ id: number; partition: RequestListPartition }> = [];
|
||||
rawRows.forEach(r => {
|
||||
const id = Number(r.ListItemID);
|
||||
if (isNaN(id) || id <= 0 || !r.Path) return;
|
||||
const partition = this.partitionForPath(siteUrl, partitions, r.Path);
|
||||
if (!partition) return;
|
||||
const key = `${partition.listId}|${id}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
hits.push({ id, partition });
|
||||
});
|
||||
if (hits.length === 0) return { items: [], hasMore: false };
|
||||
|
||||
const idsByListId = new Map<string, number[]>();
|
||||
hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
|
||||
|
||||
const rowByKey = new Map<string, IRawDataRow>();
|
||||
await Promise.all(
|
||||
Array.from(idsByListId.entries()).map(async ([listId, ids]) => {
|
||||
const list = sp.web.getList(listId);
|
||||
const dataRows = (await list.items
|
||||
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
|
||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
|
||||
.expand('Author')
|
||||
.top(ids.length)()) as IRawDataRow[];
|
||||
dataRows.forEach(r => rowByKey.set(`${listId}|${r.Id}`, r));
|
||||
})
|
||||
);
|
||||
|
||||
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
|
||||
const items = hits
|
||||
.map(h => {
|
||||
const row = rowByKey.get(`${h.partition.listId}|${h.id}`);
|
||||
return row ? toSummary(row, h.partition.listId) : undefined;
|
||||
})
|
||||
.filter((r): r is ManagedRequestSummary => !!r);
|
||||
|
||||
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
|
||||
return { items, hasMore: page * pageSize < totalRows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
|
||||
*
|
||||
* listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из
|
||||
* ManagedRequestSummary.listId, отданного searchRequests/предыдущей
|
||||
* getMatrix) — не requestType.listId: та партиция может быть уже не той, где
|
||||
* лежит эта заявка (см. ТЗ по архивации).
|
||||
*/
|
||||
public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise<ManagedRequestMatrix> {
|
||||
const sp = this.clientFor(siteUrl);
|
||||
const dataList = sp.web.getList(listId);
|
||||
|
||||
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
|
||||
dataList.items
|
||||
.getById(requestId)
|
||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
|
||||
.expand('Author')() as Promise<IRawDataRow>,
|
||||
this.listTitles(sp, listId)
|
||||
]);
|
||||
|
||||
const dataListUrl = listAbsUrl(siteUrl, listId);
|
||||
|
||||
if (!approvalListTitle) {
|
||||
return {
|
||||
request: toSummary(requestRow, listId),
|
||||
siteUrl,
|
||||
dataListTitle,
|
||||
dataListUrl,
|
||||
approvalListTitle: '',
|
||||
rows: []
|
||||
};
|
||||
}
|
||||
|
||||
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||
const rawRows = (await approvalList.items
|
||||
.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||
.select(
|
||||
'Id',
|
||||
'Title',
|
||||
FIELDS.status,
|
||||
FIELDS.date,
|
||||
FIELDS.comment,
|
||||
FIELDS.order,
|
||||
FIELDS.allowReassign,
|
||||
FIELDS.phase,
|
||||
FIELDS.slaHours,
|
||||
FIELDS.askOverdueReason,
|
||||
`${FIELDS.approver}/Id`,
|
||||
`${FIELDS.approver}/Title`,
|
||||
`${FIELDS.approver}/EMail`
|
||||
)
|
||||
.expand(FIELDS.approver)
|
||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||
|
||||
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => this.toApprovalRow(r, requestId, i));
|
||||
|
||||
return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка списка согласования (сырые OData-поля) → ManagedApprovalRow. Общий
|
||||
* маппинг для getMatrix (одна заявка, ticketId известен заранее) и
|
||||
* findActiveApprovalRowsForApprover (много заявок сразу, ticketId — из самой строки).
|
||||
*/
|
||||
private toApprovalRow(r: Record<string, unknown>, ticketId: number, index: number): ManagedApprovalRow {
|
||||
const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined;
|
||||
return {
|
||||
id: r.Id as number,
|
||||
ticketId,
|
||||
approverId: approver?.Id ?? null,
|
||||
approverName: approver?.Title || '',
|
||||
approverEmail: approver?.EMail || '',
|
||||
position: (r.Title as string) || '',
|
||||
status: (r[FIELDS.status] as string) || '',
|
||||
date: formatDateIso((r[FIELDS.date] as string) || ''),
|
||||
dateIso: (r[FIELDS.date] as string) || '',
|
||||
comment: (r[FIELDS.comment] as string) || '',
|
||||
order: (r[FIELDS.order] as number) ?? index + 1,
|
||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
|
||||
// ?? null, а не || null: ноль здесь — значимое значение («напоминать
|
||||
// сразу»), и превращать его в «не задано» нельзя.
|
||||
slaHours: (r[FIELDS.slaHours] as number | null) ?? null,
|
||||
askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
|
||||
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
|
||||
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
||||
@@ -326,166 +57,6 @@ export class RequestManageService {
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Активные строки согласования данного человека СРАЗУ по всем типам заявок и
|
||||
* всем их партициям (архив + текущая) — для экрана «Заявки по согласующему»
|
||||
* (ManageByUserScreen): найти всё, что нужно переназначить перед увольнением/
|
||||
* сменой должности, без похода в каждый тип заявки по отдельности.
|
||||
*
|
||||
* REST-only, без Search API — в отличие от request-portal/myRequests.service.ts
|
||||
* (тот ищет по текущему пользователю на КАЖДОЙ загрузке страницы, и там счёт
|
||||
* идёт на миллисекунды). Здесь редкий admin-вызов на одного конкретного
|
||||
* сотрудника, а задержка индексации Search — это ровно тот класс багов
|
||||
* (устаревшее назначение), ради исправления которого существует сам экран, так
|
||||
* что жертвовать точностью ради скорости здесь не место.
|
||||
*
|
||||
* partitionsByType — серия партиций КАЖДОГО типа (архив+текущая), уже
|
||||
* полученная вызывающей стороной через api.requestTypes.listsAll(). Partition
|
||||
* даёт Title списка данных бесплатно (без похода в REST) — approvalListTitle
|
||||
* выводится из него той же конвенцией имён, что и listTitles() выше.
|
||||
*/
|
||||
public async findActiveApprovalRowsForApprover(
|
||||
requestTypes: RequestType[],
|
||||
partitionsByType: Map<number, RequestListPartition[]>,
|
||||
approverEmail: string,
|
||||
activeStatuses: string[]
|
||||
): Promise<UserApprovalRow[]> {
|
||||
interface ITarget {
|
||||
requestTypeId: number;
|
||||
requestTypeName: string;
|
||||
siteUrl: string;
|
||||
dataListId: string;
|
||||
approvalListTitle: string;
|
||||
}
|
||||
|
||||
const targets: ITarget[] = [];
|
||||
requestTypes.forEach(rt => {
|
||||
(partitionsByType.get(rt.id) || []).forEach(p => {
|
||||
if (!p.title || !DATA_LIST_SUFFIX.test(p.title)) return;
|
||||
targets.push({
|
||||
requestTypeId: rt.id,
|
||||
requestTypeName: rt.name,
|
||||
siteUrl: p.siteUrl,
|
||||
dataListId: p.listId,
|
||||
approvalListTitle: `${p.title.replace(DATA_LIST_SUFFIX, '')} — согласование`
|
||||
});
|
||||
});
|
||||
});
|
||||
if (targets.length === 0) return [];
|
||||
|
||||
const bySite = new Map<string, ITarget[]>();
|
||||
targets.forEach(t => bySite.set(t.siteUrl, [...(bySite.get(t.siteUrl) || []), t]));
|
||||
|
||||
const statusFilter = activeStatuses.map(s => `${FIELDS.status} eq '${s}'`).join(' or ');
|
||||
const rows: UserApprovalRow[] = [];
|
||||
|
||||
await Promise.all(
|
||||
Array.from(bySite.values()).map(async siteTargets => {
|
||||
const siteUrl = siteTargets[0].siteUrl;
|
||||
try {
|
||||
const sp = this.clientFor(siteUrl);
|
||||
// Один раз на сайт — тот же приём, что в fetchRecentApprovalRows
|
||||
// request-portal: резолвить email в Id и фильтровать по approverId
|
||||
// надёжнее, чем `TansuSiteApprover/EMail eq '...'` в $filter по
|
||||
// Person-полю (последнее в SharePoint REST ненадёжно).
|
||||
const approverId = await this.resolveUserId(sp, approverEmail);
|
||||
|
||||
// Фаза A: строки согласования — ОДИН batch-запрос на все партиции сайта.
|
||||
const approvalHitsByList = new Map<string, Array<Record<string, unknown>>>();
|
||||
const [batchedApprovalSp, executeApproval] = sp.batched();
|
||||
const pendingApproval = siteTargets.map(t =>
|
||||
batchedApprovalSp.web.lists
|
||||
.getByTitle(t.approvalListTitle)
|
||||
.items.filter(`${FIELDS.approver}Id eq ${approverId} and (${statusFilter})`)
|
||||
.select(
|
||||
'Id',
|
||||
'Title',
|
||||
FIELDS.status,
|
||||
FIELDS.date,
|
||||
FIELDS.comment,
|
||||
FIELDS.order,
|
||||
FIELDS.allowReassign,
|
||||
FIELDS.phase,
|
||||
FIELDS.slaHours,
|
||||
FIELDS.askOverdueReason,
|
||||
`${FIELDS.ticket}Id`,
|
||||
`${FIELDS.approver}/Id`,
|
||||
`${FIELDS.approver}/Title`,
|
||||
`${FIELDS.approver}/EMail`
|
||||
)
|
||||
.expand(FIELDS.approver)
|
||||
.top(2000)()
|
||||
.then(items => { approvalHitsByList.set(t.dataListId, items as Array<Record<string, unknown>>); })
|
||||
.catch((e: Error) => {
|
||||
// Одна сломанная/переименованная вручную партиция не должна
|
||||
// лишить результата остальные партиции того же сайта.
|
||||
console.error(`[Заявки по согласующему] «${t.approvalListTitle}» (${t.dataListId}): ${e.message}`);
|
||||
})
|
||||
);
|
||||
await executeApproval();
|
||||
await Promise.all(pendingApproval);
|
||||
|
||||
// Фаза B: Title/статус/уникальный код заявок — по найденным Id,
|
||||
// сгруппированным по СПИСКУ ДАННЫХ, тоже одним batch-запросом на сайт.
|
||||
const ticketIdsByList = new Map<string, Set<number>>();
|
||||
siteTargets.forEach(t => {
|
||||
(approvalHitsByList.get(t.dataListId) || []).forEach(h => {
|
||||
const ticketId = h[`${FIELDS.ticket}Id`] as number | undefined;
|
||||
if (!ticketId) return;
|
||||
const set = ticketIdsByList.get(t.dataListId) || new Set<number>();
|
||||
set.add(ticketId);
|
||||
ticketIdsByList.set(t.dataListId, set);
|
||||
});
|
||||
});
|
||||
|
||||
const dataRowsByList = new Map<string, IRawDataRow[]>();
|
||||
if (ticketIdsByList.size > 0) {
|
||||
const [batchedDataSp, executeData] = sp.batched();
|
||||
const pendingData = Array.from(ticketIdsByList.entries()).map(([dataListId, idsSet]) => {
|
||||
const ids = Array.from(idsSet);
|
||||
return batchedDataSp.web
|
||||
.getList(dataListId)
|
||||
.items.filter(ids.map(id => `Id eq ${id}`).join(' or '))
|
||||
.select('Id', 'Title', 'RequestStatus', 'Created', 'RequestUniqueCode')
|
||||
.top(ids.length)()
|
||||
.then(items => { dataRowsByList.set(dataListId, items as IRawDataRow[]); })
|
||||
.catch((e: Error) => {
|
||||
console.error(`[Заявки по согласующему] список данных ${dataListId}: ${e.message}`);
|
||||
});
|
||||
});
|
||||
await executeData();
|
||||
await Promise.all(pendingData);
|
||||
}
|
||||
|
||||
// Склейка.
|
||||
siteTargets.forEach(t => {
|
||||
const hits = approvalHitsByList.get(t.dataListId) || [];
|
||||
const dataById = new Map((dataRowsByList.get(t.dataListId) || []).map(r => [r.Id, r]));
|
||||
hits.forEach((h, i) => {
|
||||
const ticketId = h[`${FIELDS.ticket}Id`] as number;
|
||||
if (!ticketId) return;
|
||||
const dataRow = dataById.get(ticketId);
|
||||
rows.push({
|
||||
...this.toApprovalRow(h, ticketId, i),
|
||||
requestTypeId: t.requestTypeId,
|
||||
requestTypeName: t.requestTypeName,
|
||||
siteUrl: t.siteUrl,
|
||||
listId: t.dataListId,
|
||||
approvalListTitle: t.approvalListTitle,
|
||||
requestTitle: dataRow?.Title || `#${ticketId}`,
|
||||
requestUniqueCode: dataRow?.RequestUniqueCode || null
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Заявки по согласующему] сайт ${siteUrl}: ${(e as Error).message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Появится ли в фазе этой заявки ВТОРОЙ согласующий с данным email, если строку
|
||||
* row переназначить на него — та же проверка, что ManageRequestsScreen делает
|
||||
@@ -516,13 +87,32 @@ export class RequestManageService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Права на заявку новому человеку в маршруте выдаёт бэкенд портала по вебхуку
|
||||
* на изменение элемента СПИСКА ДАННЫХ. Правка одного лишь списка согласования
|
||||
* вебхук не вызывает — новый человек видел бы «Элемент не найден». Поэтому
|
||||
* после смены/добавления человека пишем в заявку её текущий RequestStatus как
|
||||
* есть: значение то же, но у элемента появляется новая версия и приходит
|
||||
* вебхук (то же делает request-portal, ApproverService.touchRequest).
|
||||
*
|
||||
* dataListId — серверный относительный url списка данных (партиции), где лежит
|
||||
* заявка (ManagedRequestSummary.listId / UserApprovalRow.listId).
|
||||
*/
|
||||
private async touchRequest(siteUrl: string, dataListId: string, ticketId: number): Promise<void> {
|
||||
const item = this.clientFor(siteUrl).web.getList(dataListId).items.getById(ticketId);
|
||||
const current = (await item.select('RequestStatus')()) as { RequestStatus?: string };
|
||||
await item.update({ RequestStatus: current.RequestStatus || '' });
|
||||
}
|
||||
|
||||
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
|
||||
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
|
||||
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет.
|
||||
* request — заявка строки: по ней «трогается» элемент списка данных, см. touchRequest. */
|
||||
public async reassignApprover(
|
||||
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||
rowId: number,
|
||||
email: string,
|
||||
position: string
|
||||
position: string,
|
||||
request: { ticketId: number; dataListId: string }
|
||||
): Promise<void> {
|
||||
const sp = this.clientFor(matrix.siteUrl);
|
||||
const approverId = await this.resolveUserId(sp, email);
|
||||
@@ -530,6 +120,7 @@ export class RequestManageService {
|
||||
Title: position,
|
||||
[`${FIELDS.approver}Id`]: approverId
|
||||
});
|
||||
await this.touchRequest(matrix.siteUrl, request.dataListId, request.ticketId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -549,8 +140,8 @@ export class RequestManageService {
|
||||
): Promise<void> {
|
||||
const sp = this.clientFor(matrix.siteUrl);
|
||||
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
|
||||
[FIELDS.slaHours]: slaHours,
|
||||
[FIELDS.askOverdueReason]: askOverdueReason
|
||||
SLAhours: slaHours,
|
||||
AskOverdueReason: askOverdueReason
|
||||
});
|
||||
}
|
||||
|
||||
@@ -577,7 +168,8 @@ export class RequestManageService {
|
||||
status: string,
|
||||
email: string,
|
||||
position: string,
|
||||
allowReassign: boolean
|
||||
allowReassign: boolean,
|
||||
dataListId: string
|
||||
): Promise<void> {
|
||||
const sp = this.clientFor(matrix.siteUrl);
|
||||
const approverId = await this.resolveUserId(sp, email);
|
||||
@@ -587,9 +179,10 @@ export class RequestManageService {
|
||||
[FIELDS.status]: status,
|
||||
[`${FIELDS.ticket}Id`]: ticketId,
|
||||
[FIELDS.order]: order,
|
||||
[FIELDS.allowReassign]: allowReassign,
|
||||
AllowReassignToOtherApprovers: allowReassign,
|
||||
[FIELDS.phase]: phase
|
||||
});
|
||||
await this.touchRequest(matrix.siteUrl, dataListId, ticketId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -618,12 +211,21 @@ export class RequestManageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить уведомление согласующему/исполнителю конкретной строки маршрута —
|
||||
* тот же поток Power Automate, что и request-portal (см. FLOW_NOTIFY_URL
|
||||
* выше): поток сам находит по RowID согласующего, статус и заявку и шлёт
|
||||
* письмо. В отличие от request-portal (там вызывается автоматически после
|
||||
* решения), здесь — по явному клику на кнопку в таблице маршрута, поэтому
|
||||
* ошибку НЕ глушим — вызывающая сторона показывает её пользователю.
|
||||
* Отправить уведомление по конкретной строке маршрута — тот же поток Power
|
||||
* Automate, что и request-portal (см. FLOW_NOTIFY_URL выше): поток сам
|
||||
* находит по RowID строку, её статус и заявку.
|
||||
*
|
||||
* Обычно (статус «На согласовании»/«На исполнении») он уведомляет
|
||||
* согласующего/исполнителя ЭТОЙ строки — см. вызывающую сторону,
|
||||
* ManageRequestsPhaseTable/notifiable. Для строки в статусе «Возвращена на
|
||||
* доработку» этот же поток (по сложившейся практике портала — он уже читает
|
||||
* статус строки, см. комментарий у FLOW_NOTIFY_URL) уведомляет автора заявки,
|
||||
* а не согласующего, вернувшего её — так интерфейс редактирования заявок
|
||||
* получает кнопку «сообщить автору о доработке» без отдельного потока.
|
||||
*
|
||||
* В отличие от request-portal (там вызывается автоматически после решения),
|
||||
* здесь — по явному клику на кнопку в таблице маршрута, поэтому ошибку НЕ
|
||||
* глушим — вызывающая сторона показывает её пользователю.
|
||||
*/
|
||||
public async notifyRow(
|
||||
requestTypeId: number,
|
||||
|
||||
@@ -76,6 +76,13 @@ export const texts = {
|
||||
remove: 'Удалить',
|
||||
edit: 'Редактировать',
|
||||
close: 'Закрыть',
|
||||
/** Сброс черновика формы создания — сам черновик переживает закрытие окна. */
|
||||
resetDraft: 'Очистить',
|
||||
/** Предупреждение при выходе из формы редактирования с несохранёнными правками. */
|
||||
discardTitle: 'Закрыть без сохранения?',
|
||||
discardText: 'Внесённые изменения не сохранятся.',
|
||||
discardConfirm: 'Закрыть без сохранения',
|
||||
discardKeepEditing: 'Продолжить редактирование',
|
||||
loading: 'Загрузка...',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
@@ -93,8 +100,10 @@ export const texts = {
|
||||
title: 'Панель настройки маршрутов',
|
||||
dashboard: 'Дашборд',
|
||||
requestTypes: 'Типы заявок',
|
||||
requestTypeAccess: 'Доступ к заявкам',
|
||||
routes: 'Маршруты',
|
||||
resolve: 'Проверка маршрута',
|
||||
routesExport: 'Выгрузка маршрутов',
|
||||
manageRequests: 'Редактирование заявок',
|
||||
manageByUser: 'Заявки по участнику'
|
||||
},
|
||||
@@ -178,6 +187,53 @@ export const texts = {
|
||||
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
|
||||
},
|
||||
|
||||
requestTypeAccess: {
|
||||
breadcrumb: 'Доступ к заявкам',
|
||||
subtitle: 'Кто видит все заявки типа, а не только свои',
|
||||
title: 'Доступ к заявкам',
|
||||
add: 'Выдать доступ',
|
||||
emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.',
|
||||
emptyFiltered: 'Ничего не найдено по заданным фильтрам.',
|
||||
filterRequestTypeLabel: 'Фильтр по типу заявки',
|
||||
filterRequestTypeAll: 'Все типы заявок',
|
||||
filterEmailLabel: 'Фильтр по сотруднику',
|
||||
filterEmailAll: 'Все сотрудники',
|
||||
filterEmailPlaceholder: 'Найдите по ФИО или email',
|
||||
columnRequestType: 'Тип заявки',
|
||||
columnEmail: 'Сотрудник',
|
||||
columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)',
|
||||
createTitle: 'Выдать доступ',
|
||||
requestTypeLabel: 'Тип заявки',
|
||||
requestTypePlaceholder: 'Выберите тип заявки',
|
||||
emailLabel: 'Сотрудники',
|
||||
emailPlaceholder: 'Добавьте сотрудника — найдите по ФИО или email',
|
||||
removeEmail: 'Убрать из списка',
|
||||
alreadyGranted: 'доступ уже есть',
|
||||
alreadyChosen: 'уже в списке',
|
||||
noEmailsSelected: 'Пока никого не выбрано.',
|
||||
createFailed: (failures: string[]): string =>
|
||||
`Не удалось выдать доступ (остались в списке): ${failures.join('; ')}`,
|
||||
validationRequestType: 'Выберите тип заявки.',
|
||||
validationEmail: 'Выберите хотя бы одного сотрудника.',
|
||||
deleteTitle: 'Отозвать доступ?',
|
||||
deleteText: (email: string, requestTypeName: string): string =>
|
||||
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
|
||||
},
|
||||
|
||||
routesExport: {
|
||||
breadcrumb: 'Выгрузка маршрутов',
|
||||
subtitle: 'Маршруты согласования в Excel',
|
||||
title: 'Выгрузка маршрутов',
|
||||
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (согласующие и исполнители отдельными таблицами).',
|
||||
selectAll: 'Выбрать все',
|
||||
export: 'Выгрузить',
|
||||
exporting: 'Формирование файла...',
|
||||
emptyTypes: 'Типов заявок пока нет.',
|
||||
selected: (count: number): string => `Выбрано: ${count}`,
|
||||
fileName: 'Маршруты согласования.xlsx',
|
||||
failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}`
|
||||
},
|
||||
|
||||
routes: {
|
||||
breadcrumb: 'Маршруты согласования',
|
||||
subtitle: 'Настройка согласований',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// ISO 8601 (или пустая строка) → «дд.мм.гггг» для отображения в таблицах
|
||||
// маршрута. Раздельно от dateIso: последняя нужна для расчёта просрочки
|
||||
// (см. utils/sla.ts) и не должна терять точность через локаль при парсинге
|
||||
// уже отформатированной строки назад.
|
||||
export function formatDateIso(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user