Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a46782920b | |||
| f3b0f0ad48 | |||
| 0e6a7064d2 |
@@ -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();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Tnsu.ApprovalRoute.Application.Approvals;
|
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||||
|
using Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
using Tnsu.ApprovalRoute.Application.Reports;
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
@@ -21,6 +22,7 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddScoped<RequestTypeService>();
|
services.AddScoped<RequestTypeService>();
|
||||||
services.AddScoped<RequestTypeAccessService>();
|
services.AddScoped<RequestTypeAccessService>();
|
||||||
services.AddScoped<RequestManageService>();
|
services.AddScoped<RequestManageService>();
|
||||||
|
services.AddScoped<BasisRequestsService>();
|
||||||
services.AddScoped<ListPartitionRegistry>();
|
services.AddScoped<ListPartitionRegistry>();
|
||||||
services.AddScoped<ListPartitionService>();
|
services.AddScoped<ListPartitionService>();
|
||||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
@@ -44,25 +45,6 @@ public sealed class RouteExportService(
|
|||||||
["CompaniesJson"] = "организация"
|
["CompaniesJson"] = "организация"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly Dictionary<ProjectRole, string> ProjectRoleLabels = new()
|
|
||||||
{
|
|
||||||
[ProjectRole.ProjectManager] = "Руководитель проекта",
|
|
||||||
[ProjectRole.Planner] = "Планировщик",
|
|
||||||
[ProjectRole.ManagerHSE] = "Менеджер HSE",
|
|
||||||
[ProjectRole.Security] = "Безопасность",
|
|
||||||
[ProjectRole.Keeper] = "Заведующий склада"
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Dictionary<HeadType, string> HeadTypeLabels = new()
|
|
||||||
{
|
|
||||||
[HeadType.Chief] = "Непосредственный руководитель",
|
|
||||||
[HeadType.DirectionHead] = "Руководитель направления",
|
|
||||||
[HeadType.DirectorTansu] = "Директор Tansu",
|
|
||||||
[HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu",
|
|
||||||
[HeadType.DepartmentHead] = "Руководитель указанного отдела",
|
|
||||||
[HeadType.CompanyDirectors] = "Директора компаний инициатора"
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new()
|
private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new()
|
||||||
{
|
{
|
||||||
[ComparisonOperator.GreaterThan] = ">",
|
[ComparisonOperator.GreaterThan] = ">",
|
||||||
@@ -228,7 +210,7 @@ public sealed class RouteExportService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None)
|
if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None)
|
||||||
return (ProjectRoleLabels[projectRole], ByRequest, Empty);
|
return (StepLabels.Of(projectRole), ByRequest, Empty);
|
||||||
|
|
||||||
if (step.HeadType is { } headType && headType != HeadType.None)
|
if (step.HeadType is { } headType && headType != HeadType.None)
|
||||||
{
|
{
|
||||||
@@ -240,9 +222,9 @@ public sealed class RouteExportService(
|
|||||||
};
|
};
|
||||||
if (fixedEmail is not null)
|
if (fixedEmail is not null)
|
||||||
// Нет в справочнике — лучше показать email, чем пустую ячейку.
|
// Нет в справочнике — лучше показать email, чем пустую ячейку.
|
||||||
return (HeadTypeLabels[headType], directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
|
return (StepLabels.Of(headType), directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
|
||||||
|
|
||||||
return (HeadTypeLabels[headType], ByRequest, Empty);
|
return (StepLabels.Of(headType), ByRequest, Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (Empty, Empty, Empty);
|
return (Empty, Empty, Empty);
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
@@ -82,7 +83,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
var entity = new TemplateRouteStep
|
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))
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo;
|
entity.OrderNo = r.OrderNo;
|
||||||
entity.ExecutorEmail = r.ExecutorEmail;
|
entity.ExecutorEmail = r.ExecutorEmail;
|
||||||
@@ -210,7 +211,7 @@ public sealed class TemplateRouteStepService(
|
|||||||
|
|
||||||
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
|
$"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||||
entity.ExecutorEmail = executorEmail;
|
entity.ExecutorEmail = executorEmail;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using FluentResults;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
@@ -437,7 +438,7 @@ public sealed class TemplateRouteService(
|
|||||||
{
|
{
|
||||||
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
|
warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
|
||||||
}
|
}
|
||||||
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
||||||
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
||||||
@@ -469,7 +470,14 @@ public sealed class TemplateRouteService(
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
warning = $"Не найден руководитель типа «{step.HeadType}».";
|
{
|
||||||
|
var headLabel = StepLabels.InSentence(step.HeadType.Value);
|
||||||
|
// Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
|
||||||
|
// справочника сотрудников — там email не «не найден», а «не проставлен».
|
||||||
|
warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
|
||||||
|
? $"Не проставлен {headLabel}."
|
||||||
|
: $"Не найден {headLabel}.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -612,7 +620,7 @@ public sealed class TemplateRouteService(
|
|||||||
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
||||||
{
|
{
|
||||||
var conflictWarning =
|
var conflictWarning =
|
||||||
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({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 }
|
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
||||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||||
result[index] = existing with { Warning = combinedWarning };
|
result[index] = existing with { Warning = combinedWarning };
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ public interface ISharePointClient
|
|||||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||||
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
|
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
|
||||||
/// элементы, а не только доступные пользователю. Возвращает строки результата
|
/// элементы, а не только доступные пользователю. Возвращает строки результата
|
||||||
@@ -229,6 +237,22 @@ public sealed class SharePointClient(
|
|||||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
||||||
cancellationToken);
|
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(
|
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||||
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
|
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -426,6 +450,8 @@ public sealed class SharePointClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const ulong ViewListItemsMask = 0x1;
|
||||||
|
|
||||||
// Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки
|
// Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки
|
||||||
// списков содержат тире и кириллицу, но кавычка теоретически возможна —
|
// списков содержат тире и кириллицу, но кавычка теоретически возможна —
|
||||||
// без экранирования она сломала бы запрос.
|
// без экранирования она сломала бы запрос.
|
||||||
|
|||||||
+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);
|
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldContain("Chief");
|
warning.ShouldContain("Непосредственный руководитель");
|
||||||
warning.ShouldContain("DirectionHead");
|
warning.ShouldContain("Руководитель направления");
|
||||||
|
warning.ShouldNotContain("DirectionHead");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
||||||
@@ -1050,7 +1051,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldContain("руководитель");
|
warning.ShouldBe("Не найден непосредственный руководитель.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
|
||||||
@@ -1108,7 +1109,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
var warning = resolved.Value.Steps[0].Warning;
|
var warning = resolved.Value.Steps[0].Warning;
|
||||||
warning.ShouldNotBeNull();
|
warning.ShouldNotBeNull();
|
||||||
warning.ShouldContain("роль");
|
warning.ShouldBe("В проекте не найден сотрудник службы безопасности.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
||||||
|
|||||||
+6
-1
@@ -74,6 +74,11 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
|
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
|
||||||
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
|
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
|
||||||
|
|
||||||
|
const isDirty =
|
||||||
|
email !== initialEmail ||
|
||||||
|
slaHours !== slaHoursToInput(initialSlaHours) ||
|
||||||
|
askOverdueReason !== boolToTriState(initialAskOverdueReason);
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
const trimmedEmail = email.trim();
|
const trimmedEmail = email.trim();
|
||||||
if (!trimmedEmail) {
|
if (!trimmedEmail) {
|
||||||
@@ -95,7 +100,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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>
|
<FormGrid>
|
||||||
<Field label={texts.manageRequests.approverLabel} required full>
|
<Field label={texts.manageRequests.approverLabel} required full>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
|
|||||||
+21
@@ -10,3 +10,24 @@
|
|||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chosen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosenItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 8px 4px 12px;
|
||||||
|
border: 1px solid #e1e4ea;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosenEmpty {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|||||||
+88
-18
@@ -9,6 +9,8 @@ import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
|||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
|
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import { toEmployeeOption } from '../../utils/employee';
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
import { replaceRoute } from '../../routing';
|
import { replaceRoute } from '../../routing';
|
||||||
import { RequestTypeAccess } from '../../models';
|
import { RequestTypeAccess } from '../../models';
|
||||||
@@ -41,6 +43,17 @@ export interface IRequestTypeAccessScreenProps {
|
|||||||
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
|
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
|
||||||
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
|
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
|
||||||
|
|
||||||
|
/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */
|
||||||
|
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess';
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestTypeAccess';
|
||||||
|
|
||||||
|
interface IFormState {
|
||||||
|
requestTypeId: string;
|
||||||
|
emails: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: IFormState = { requestTypeId: '', emails: [] };
|
||||||
|
|
||||||
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
|
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
|
||||||
api,
|
api,
|
||||||
dictionary,
|
dictionary,
|
||||||
@@ -51,9 +64,11 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
const access = useRequestTypeAccess(api);
|
const access = useRequestTypeAccess(api);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||||
const [requestTypeId, setRequestTypeId] = React.useState('');
|
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
|
||||||
const [email, setEmail] = React.useState('');
|
const { requestTypeId, emails } = draft.value;
|
||||||
|
const setEmails = (update: (current: string[]) => string[]): void =>
|
||||||
|
draft.setValue(f => ({ ...f, emails: update(f.emails) }));
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
||||||
@@ -96,9 +111,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
|
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setRequestTypeId('');
|
createOpen.open();
|
||||||
setEmail('');
|
|
||||||
setShowForm(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitForm = (): void => {
|
const submitForm = (): void => {
|
||||||
@@ -106,17 +119,57 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
submit.fail(texts.requestTypeAccess.validationRequestType);
|
submit.fail(texts.requestTypeAccess.validationRequestType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!email.trim()) {
|
if (emails.length === 0) {
|
||||||
submit.fail(texts.requestTypeAccess.validationEmail);
|
submit.fail(texts.requestTypeAccess.validationEmail);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
submit.run(() =>
|
// По одному запросу на сотрудника (бэкенд выдаёт доступ по одному). Падение на
|
||||||
api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email: email.trim() }).then(() => {
|
// одном не отменяет остальных: неудавшиеся остаются в списке с причиной, чтобы
|
||||||
setShowForm(false);
|
// можно было поправить и нажать ещё раз.
|
||||||
access.reload();
|
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 (
|
return (
|
||||||
@@ -189,13 +242,15 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{createOpen.isOpen && (
|
||||||
<FormModal
|
<FormModal
|
||||||
title={texts.requestTypeAccess.createTitle}
|
title={texts.requestTypeAccess.createTitle}
|
||||||
error={submit.error}
|
error={submit.error}
|
||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={createOpen.close}
|
||||||
|
onReset={draft.reset}
|
||||||
|
canReset={draft.isDirty}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
|
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
|
||||||
@@ -203,19 +258,34 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
|
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
|
||||||
value={requestTypeId}
|
value={requestTypeId}
|
||||||
onChange={e => setRequestTypeId(e.target.value)}
|
onChange={e => draft.setValue(f => ({ ...f, requestTypeId: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
||||||
|
<div>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={employees.items.map(toEmployeeOption)}
|
options={employeeOptions}
|
||||||
value={email}
|
value=""
|
||||||
loading={employees.isLoading}
|
loading={employees.isLoading}
|
||||||
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
emptyMessage={texts.select.employeesEmpty}
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
onChange={setEmail}
|
onChange={value => value && setEmails(current => [...current, value])}
|
||||||
/>
|
/>
|
||||||
|
<div className={styles.chosen}>
|
||||||
|
{emails.length === 0 && <span className={styles.chosenEmpty}>{texts.requestTypeAccess.noEmailsSelected}</span>}
|
||||||
|
{emails.map(email => (
|
||||||
|
<div key={email} className={styles.chosenItem}>
|
||||||
|
<span>{employeeLabel(email)}</span>
|
||||||
|
<IconButton
|
||||||
|
icon="close"
|
||||||
|
label={texts.requestTypeAccess.removeEmail}
|
||||||
|
onClick={() => setEmails(current => current.filter(x => x !== email))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
|||||||
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -57,6 +59,22 @@ const EMPTY_FORM: IFormState = {
|
|||||||
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
|
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
||||||
|
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType';
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestType';
|
||||||
|
|
||||||
|
function toFormState(item: RequestType): IFormState {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
pageUrl: item.pageUrl,
|
||||||
|
section: item.section,
|
||||||
|
siteUrl: item.siteUrl ?? '',
|
||||||
|
listId: item.listId ?? '',
|
||||||
|
slaHours: slaHoursToInput(item.slaHours),
|
||||||
|
askOverdueReason: boolToTriState(item.askOverdueReason)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
||||||
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
||||||
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
||||||
@@ -78,8 +96,18 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
const list = useRequestTypes(api);
|
const list = useRequestTypes(api);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const [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 submit = useAsyncSubmit();
|
||||||
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
||||||
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
||||||
@@ -92,21 +120,14 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setForm(EMPTY_FORM);
|
setShowForm(false);
|
||||||
setShowForm(true);
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: RequestType): void => {
|
const openEdit = (item: RequestType): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setForm({
|
setEditForm(toFormState(item));
|
||||||
name: item.name,
|
createOpen.close();
|
||||||
pageUrl: item.pageUrl,
|
|
||||||
section: item.section,
|
|
||||||
siteUrl: item.siteUrl ?? '',
|
|
||||||
listId: item.listId ?? '',
|
|
||||||
slaHours: slaHoursToInput(item.slaHours),
|
|
||||||
askOverdueReason: boolToTriState(item.askOverdueReason)
|
|
||||||
});
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +164,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(() => {
|
request.then(() => {
|
||||||
setShowForm(false);
|
if (!editing) draft.reset();
|
||||||
|
closeForm();
|
||||||
list.reload();
|
list.reload();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -192,13 +214,16 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<FormModal
|
<FormModal
|
||||||
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
||||||
error={submit.error}
|
error={submit.error}
|
||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
|
onReset={editing ? undefined : draft.reset}
|
||||||
|
canReset={draft.isDirty}
|
||||||
|
isDirty={isEditDirty}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.requestTypes.nameLabel} required full>
|
<Field label={texts.requestTypes.nameLabel} required full>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { Company, Department, RequestType, TemplateRoute } from '../../models';
|
import { Company, Department, RequestType, TemplateRoute } from '../../models';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ interface IFormState {
|
|||||||
requiresControl: boolean;
|
requiresControl: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
||||||
|
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.route';
|
||||||
|
|
||||||
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
return {
|
return {
|
||||||
@@ -58,7 +62,10 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
onSaved,
|
onSaved,
|
||||||
onClose
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId));
|
// Редактирование не персистим — форма всегда открывается с данными маршрута с сервера.
|
||||||
|
const draft = useFormDraft<IFormState>(editing ? undefined : CREATE_DRAFT_STORAGE_KEY, toFormState(editing, initialRequestTypeId));
|
||||||
|
const form = draft.value;
|
||||||
|
const setForm = draft.setValue;
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
||||||
@@ -93,6 +100,7 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(() => {
|
request.then(() => {
|
||||||
|
draft.reset();
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
@@ -106,6 +114,9 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
|||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onReset={editing ? undefined : draft.reset}
|
||||||
|
canReset={draft.isDirty}
|
||||||
|
isDirty={Boolean(editing) && draft.isDirty}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.routes.requestTypeLabel} required full>
|
<Field label={texts.routes.requestTypeLabel} required full>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||||||
@@ -119,6 +120,9 @@ const IS_MANAGER_VALUE_OPTIONS = [
|
|||||||
{ value: 'false', label: texts.common.no }
|
{ value: 'false', label: texts.common.no }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Черновик формы создания шага — свой у каждого маршрута, переживает закрытие модалки (см. useFormDraft). */
|
||||||
|
const createDraftStorageKey = (routeId: number): string => `approvalAdmin.draft.routeStep.${routeId}`;
|
||||||
|
|
||||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
return {
|
return {
|
||||||
@@ -170,7 +174,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
onSaved,
|
onSaved,
|
||||||
onClose
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
// Редактирование не персистим — форма всегда открывается с данными шага с сервера.
|
||||||
|
const draft = useFormDraft<IFormState>(editing ? undefined : createDraftStorageKey(route.id), toFormState(editing, nextOrderNo));
|
||||||
|
const form = draft.value;
|
||||||
|
const setForm = draft.setValue;
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
const hasListId = Boolean(requestType?.listId);
|
const hasListId = Boolean(requestType?.listId);
|
||||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||||
@@ -400,6 +407,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
|
|
||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(step => {
|
request.then(step => {
|
||||||
|
draft.reset();
|
||||||
onSaved(step);
|
onSaved(step);
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
@@ -413,6 +421,9 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onReset={editing ? undefined : draft.reset}
|
||||||
|
canReset={draft.isDirty}
|
||||||
|
isDirty={Boolean(editing) && draft.isDirty}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={texts.routeSteps.orderNoLabel} required>
|
<Field label={texts.routeSteps.orderNoLabel} required>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u
|
|||||||
import { reportError } from '../../utils/errors';
|
import { reportError } from '../../utils/errors';
|
||||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +54,14 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen),
|
||||||
|
// ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const createOpen = usePersistedOpen(`approvalAdmin.draftOpen.routeStep.${route.id}`);
|
||||||
|
const closeForm = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
createOpen.close();
|
||||||
|
};
|
||||||
|
|
||||||
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
||||||
item => api.templateRouteSteps.remove(route.id, item.id),
|
item => api.templateRouteSteps.remove(route.id, item.id),
|
||||||
@@ -71,11 +79,13 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(true);
|
setShowForm(false);
|
||||||
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRouteStep): void => {
|
const openEdit = (item: TemplateRouteStep): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
|
createOpen.close();
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,7 +288,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<RouteStepFormModal
|
<RouteStepFormModal
|
||||||
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
||||||
// применяется к любой компании инициатора) — тогда подсказка
|
// применяется к любой компании инициатора) — тогда подсказка
|
||||||
@@ -293,7 +303,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
employees={employees.items}
|
employees={employees.items}
|
||||||
employeesLoading={employees.isLoading}
|
employeesLoading={employees.isLoading}
|
||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
|||||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { replaceRoute } from '../../routing';
|
import { replaceRoute } from '../../routing';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,8 @@ export interface IRoutesScreenProps {
|
|||||||
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
||||||
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
||||||
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
||||||
|
/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route';
|
||||||
|
|
||||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||||
api,
|
api,
|
||||||
@@ -69,7 +72,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||||
|
const closeForm = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
createOpen.close();
|
||||||
|
};
|
||||||
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
||||||
|
|
||||||
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||||
@@ -83,11 +92,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(true);
|
setShowForm(false);
|
||||||
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRoute): void => {
|
const openEdit = (item: TemplateRoute): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
|
createOpen.close();
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +170,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<RouteFormModal
|
<RouteFormModal
|
||||||
api={api}
|
api={api}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@@ -169,7 +180,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
departments={portalDictionaries.departments}
|
departments={portalDictionaries.departments}
|
||||||
dictionaryLoading={portalDictionaries.isLoading}
|
dictionaryLoading={portalDictionaries.isLoading}
|
||||||
onSaved={routes.reload}
|
onSaved={routes.reload}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
box-shadow: $sh-1;
|
box-shadow: $sh-1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@include card-hover-gold;
|
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
// Миксин с вложенным &:hover — после всех объявлений, иначе Sass ругается mixed-decls.
|
||||||
|
@include card-hover-gold;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Modal.module.scss';
|
import styles from './Modal.module.scss';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
import { ModalOverlay } from './ModalOverlay';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IConfirmDialogProps {
|
export interface IConfirmDialogProps {
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
|
/** Подпись кнопки отказа — по умолчанию «Отмена». */
|
||||||
|
cancelLabel?: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
@@ -16,18 +19,19 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
|
|||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
|
cancelLabel = texts.common.cancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy
|
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.box} ${styles.narrow}`}>
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.title}>{title}</div>
|
||||||
<p className={styles.text}>{text}</p>
|
<p className={styles.text}>{text}</p>
|
||||||
<div className={styles.narrowFooter}>
|
<div className={styles.narrowFooter}>
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={busy}>{texts.common.cancel}</Button>
|
<Button variant="secondary" onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
||||||
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert } from './Alert';
|
import { Alert } from './Alert';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
import { ConfirmDialog } from './ConfirmDialog';
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
|
import styles from './Modal.module.scss';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IFormModalProps {
|
export interface IFormModalProps {
|
||||||
@@ -15,6 +17,18 @@ export interface IFormModalProps {
|
|||||||
savingLabel?: string;
|
savingLabel?: string;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается,
|
||||||
|
* только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают.
|
||||||
|
*/
|
||||||
|
onReset?: () => void;
|
||||||
|
canReset?: boolean;
|
||||||
|
/**
|
||||||
|
* В форме есть несохранённые правки существующей записи: выход (крестик,
|
||||||
|
* «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания
|
||||||
|
* не нужен — их черновик сохраняется сам (см. useFormDraft).
|
||||||
|
*/
|
||||||
|
isDirty?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,14 +44,32 @@ export const FormModal: React.FC<IFormModalProps> = ({
|
|||||||
savingLabel = texts.common.saving,
|
savingLabel = texts.common.saving,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
onClose,
|
||||||
|
onReset,
|
||||||
|
canReset,
|
||||||
|
isDirty,
|
||||||
children
|
children
|
||||||
}) => (
|
}) => {
|
||||||
|
const [confirmingClose, setConfirmingClose] = React.useState(false);
|
||||||
|
// Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл.
|
||||||
|
const requestClose = (): void => {
|
||||||
|
if (isSaving) return;
|
||||||
|
if (isDirty) setConfirmingClose(true);
|
||||||
|
else onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
title={title}
|
title={title}
|
||||||
onClose={onClose}
|
onClose={requestClose}
|
||||||
footer={
|
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}>
|
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
||||||
{isSaving ? savingLabel : submitLabel}
|
{isSaving ? savingLabel : submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -47,4 +79,16 @@ export const FormModal: React.FC<IFormModalProps> = ({
|
|||||||
{error && <Alert kind="err">{error}</Alert>}
|
{error && <Alert kind="err">{error}</Alert>}
|
||||||
{children}
|
{children}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
{confirmingClose && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={texts.common.discardTitle}
|
||||||
|
text={texts.common.discardText}
|
||||||
|
confirmLabel={texts.common.discardConfirm}
|
||||||
|
cancelLabel={texts.common.discardKeepEditing}
|
||||||
|
onConfirm={onClose}
|
||||||
|
onCancel={() => setConfirmingClose(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,9 +7,17 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 300;
|
// Выше шапки SharePoint, но ниже слоёв Fluent UI (1000000). Оверлей рендерится
|
||||||
|
// порталом в body (см. ModalOverlay), поэтому сравнивается с шапкой напрямую.
|
||||||
|
z-index: 100000;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
// Портал вне корня веб-части — шрифт и метрики .shell (AdminApp.module.scss)
|
||||||
|
// сюда не наследуются, задаём заново.
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
line-height: 1.55;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
@@ -63,4 +71,7 @@
|
|||||||
padding: 16px 26px 22px;
|
padding: 16px 26px 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Кнопка в подвале, прижатая влево, подальше от «Отмена/Сохранить» (см. «Очистить» в FormModal). */
|
||||||
|
.footerStart { margin-right: auto; }
|
||||||
|
|
||||||
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Modal.module.scss';
|
import styles from './Modal.module.scss';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
|
import { ModalOverlay } from './ModalOverlay';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IModalProps {
|
export interface IModalProps {
|
||||||
@@ -11,8 +12,7 @@ export interface IModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
||||||
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
|
<ModalOverlay onBackdropClick={onClose}>
|
||||||
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
||||||
<div className={styles.box}>
|
<div className={styles.box}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.title}>{title}</div>
|
||||||
@@ -21,5 +21,5 @@ export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children
|
|||||||
<div className={styles.body}>{children}</div>
|
<div className={styles.body}>{children}</div>
|
||||||
{footer && <div className={styles.footer}>{footer}</div>}
|
{footer && <div className={styles.footer}>{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</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';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может
|
// Ниже Modal.module.scss/.overlay (z-index 100000) — диалоги, которые может
|
||||||
// открыть сама длительная операция (например, разрешение конфликта), должны
|
// открыть сама длительная операция (например, разрешение конфликта), должны
|
||||||
// быть ПОВЕРХ этой подложки, а не под ней.
|
// быть ПОВЕРХ этой подложки, а не под ней.
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
box-shadow: $sh-modal;
|
box-shadow: $sh-modal;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
z-index: 400;
|
z-index: 100001; // над оверлеем модалки (Modal.module.scss)
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -116,6 +116,7 @@
|
|||||||
|
|
||||||
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
||||||
&.selected { font-weight: 600; color: $navy-900; }
|
&.selected { font-weight: 600; color: $navy-900; }
|
||||||
|
&:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemSub {
|
.itemSub {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface ISearchableOption {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Второстепенная строка под названием — например, компания у отдела. */
|
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
|
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISearchableSelectProps {
|
export interface ISearchableSelectProps {
|
||||||
@@ -135,6 +137,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||||
|
disabled={opt.disabled}
|
||||||
onClick={() => pick(opt.value)}
|
onClick={() => pick(opt.value)}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from './PrimaryCell';
|
|||||||
export * from './ActionsCell';
|
export * from './ActionsCell';
|
||||||
export * from './CellNote';
|
export * from './CellNote';
|
||||||
export * from './Modal';
|
export * from './Modal';
|
||||||
|
export * from './ModalOverlay';
|
||||||
export * from './ConfirmDialog';
|
export * from './ConfirmDialog';
|
||||||
export * from './FormModal';
|
export * from './FormModal';
|
||||||
export * from './FormGrid';
|
export * from './FormGrid';
|
||||||
|
|||||||
@@ -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, но значение переживает перезагрузку страницы — хранится
|
* Сохранённое значение, приведённое к форме initial. Запись могла быть сделана
|
||||||
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
* старой версией формы, поэтому ей нельзя доверять слепо:
|
||||||
* квота) не ломает форму — просто не персистит.
|
* - объект мёржится с initial — добавленные с тех пор поля получают дефолт;
|
||||||
*
|
* - ключи, которых в initial нет (поле удалили), отбрасываются;
|
||||||
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
|
* - значение, чей тип не совпал с initial (было число — стала строка, было
|
||||||
* обновления в форму добавили новое поле, у пользователей со старой записью в
|
* [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер;
|
||||||
* localStorage оно не потеряется как undefined (что уронило бы рендер на
|
* - null/undefined в initial типа не задают — там принимаем любое сохранённое.
|
||||||
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
|
|
||||||
*/
|
*/
|
||||||
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
export function coerceStored<T>(initial: T, stored: unknown): T {
|
||||||
const [value, setValue] = React.useState<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 {
|
try {
|
||||||
const raw = window.localStorage.getItem(key);
|
const raw = window.localStorage.getItem(key);
|
||||||
if (!raw) return initial;
|
if (!raw) return initial;
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as T;
|
return coerceStored(initial, JSON.parse(raw));
|
||||||
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
|
|
||||||
} catch {
|
} catch {
|
||||||
return initial;
|
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(() => {
|
React.useEffect(() => {
|
||||||
try {
|
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) };
|
||||||
|
}
|
||||||
@@ -76,6 +76,13 @@ export const texts = {
|
|||||||
remove: 'Удалить',
|
remove: 'Удалить',
|
||||||
edit: 'Редактировать',
|
edit: 'Редактировать',
|
||||||
close: 'Закрыть',
|
close: 'Закрыть',
|
||||||
|
/** Сброс черновика формы создания — сам черновик переживает закрытие окна. */
|
||||||
|
resetDraft: 'Очистить',
|
||||||
|
/** Предупреждение при выходе из формы редактирования с несохранёнными правками. */
|
||||||
|
discardTitle: 'Закрыть без сохранения?',
|
||||||
|
discardText: 'Внесённые изменения не сохранятся.',
|
||||||
|
discardConfirm: 'Закрыть без сохранения',
|
||||||
|
discardKeepEditing: 'Продолжить редактирование',
|
||||||
loading: 'Загрузка...',
|
loading: 'Загрузка...',
|
||||||
yes: 'Да',
|
yes: 'Да',
|
||||||
no: 'Нет',
|
no: 'Нет',
|
||||||
@@ -198,10 +205,16 @@ export const texts = {
|
|||||||
createTitle: 'Выдать доступ',
|
createTitle: 'Выдать доступ',
|
||||||
requestTypeLabel: 'Тип заявки',
|
requestTypeLabel: 'Тип заявки',
|
||||||
requestTypePlaceholder: 'Выберите тип заявки',
|
requestTypePlaceholder: 'Выберите тип заявки',
|
||||||
emailLabel: 'Сотрудник',
|
emailLabel: 'Сотрудники',
|
||||||
emailPlaceholder: 'Найдите по ФИО или email',
|
emailPlaceholder: 'Добавьте сотрудника — найдите по ФИО или email',
|
||||||
|
removeEmail: 'Убрать из списка',
|
||||||
|
alreadyGranted: 'доступ уже есть',
|
||||||
|
alreadyChosen: 'уже в списке',
|
||||||
|
noEmailsSelected: 'Пока никого не выбрано.',
|
||||||
|
createFailed: (failures: string[]): string =>
|
||||||
|
`Не удалось выдать доступ (остались в списке): ${failures.join('; ')}`,
|
||||||
validationRequestType: 'Выберите тип заявки.',
|
validationRequestType: 'Выберите тип заявки.',
|
||||||
validationEmail: 'Выберите сотрудника.',
|
validationEmail: 'Выберите хотя бы одного сотрудника.',
|
||||||
deleteTitle: 'Отозвать доступ?',
|
deleteTitle: 'Отозвать доступ?',
|
||||||
deleteText: (email: string, requestTypeName: string): string =>
|
deleteText: (email: string, requestTypeName: string): string =>
|
||||||
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
|
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
|
||||||
|
|||||||
Reference in New Issue
Block a user