Feature/request type access #23
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройка доступа «видеть все заявки типа», а не только свои. Список
|
||||||
|
/// email'ов по типу дёргает и портал заявок (проверить текущего пользователя),
|
||||||
|
/// и Power Automate — поэтому политика AnyAuth, как у RemindersController.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthPolicies.Any)]
|
||||||
|
[Route("api/request-type-access")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestTypeAccessController(RequestTypeAccessService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeAccessDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{requestTypeId:int}/emails")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ListEmails(int requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListEmailsAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeAccessDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Create(CreateRequestTypeAccessRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ 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;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestManage;
|
using Tnsu.ApprovalRoute.Application.RequestManage;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
@@ -18,6 +19,7 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.AddScoped<ReminderService>();
|
services.AddScoped<ReminderService>();
|
||||||
services.AddScoped<RequestTypeService>();
|
services.AddScoped<RequestTypeService>();
|
||||||
|
services.AddScoped<RequestTypeAccessService>();
|
||||||
services.AddScoped<RequestManageService>();
|
services.AddScoped<RequestManageService>();
|
||||||
services.AddScoped<ListPartitionRegistry>();
|
services.AddScoped<ListPartitionRegistry>();
|
||||||
services.AddScoped<ListPartitionService>();
|
services.AddScoped<ListPartitionService>();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
/// <param name="SharePointLists">
|
||||||
|
/// Вся серия SharePoint-списков данных (архив + текущая партиция) типа заявки —
|
||||||
|
/// именно на них нужно выдать email'у право чтения, иначе поиск по порталу
|
||||||
|
/// продолжит отдавать только его собственные заявки: доступ здесь настраивает
|
||||||
|
/// видимость в UI approval-route, но не права SharePoint, которые остаются
|
||||||
|
/// отдельным ручным шагом.
|
||||||
|
/// </param>
|
||||||
|
public sealed record RequestTypeAccessDto(
|
||||||
|
int Id,
|
||||||
|
int RequestTypeId,
|
||||||
|
string Email,
|
||||||
|
IReadOnlyList<RequestListPartitionDto> SharePointLists);
|
||||||
|
|
||||||
|
public sealed record CreateRequestTypeAccessRequest(int RequestTypeId, string Email);
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
public sealed class RequestTypeAccessService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ListPartitionService partitions,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
ILogger<RequestTypeAccessService> logger)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<RequestTypeAccessDto>>> ListAsync(
|
||||||
|
int? requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entities = await db.RequestTypeAccesses.AsNoTracking()
|
||||||
|
.Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId)
|
||||||
|
.OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Партиции запрашиваются РАЗ на тип заявки, а не на строку — иначе список
|
||||||
|
// из N email по одному типу заново обходил бы SharePoint N раз подряд.
|
||||||
|
var listsByType = new Dictionary<int, IReadOnlyList<RequestListPartitionDto>>();
|
||||||
|
var items = new List<RequestTypeAccessDto>(entities.Count);
|
||||||
|
foreach (var e in entities)
|
||||||
|
{
|
||||||
|
var lists = await GetListsCachedAsync(listsByType, e.RequestTypeId, cancellationToken);
|
||||||
|
items.Add(new RequestTypeAccessDto(e.Id, e.RequestTypeId, e.Email, lists));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<RequestTypeAccessDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<RequestListPartitionDto>> GetListsCachedAsync(
|
||||||
|
Dictionary<int, IReadOnlyList<RequestListPartitionDto>> cache,
|
||||||
|
int requestTypeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cache.TryGetValue(requestTypeId, out var cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var result = await partitions.ListAsync(requestTypeId, cancellationToken);
|
||||||
|
var lists = result.IsSuccess ? result.Value : Array.Empty<RequestListPartitionDto>();
|
||||||
|
cache[requestTypeId] = lists;
|
||||||
|
return lists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Список email'ов с доступом к типу заявок — дёргает Power Automate.</summary>
|
||||||
|
public async Task<Result<IReadOnlyList<string>>> ListEmailsAsync(int requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == requestTypeId, cancellationToken))
|
||||||
|
return Result.Fail<IReadOnlyList<string>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||||
|
|
||||||
|
var emails = await db.RequestTypeAccesses.AsNoTracking()
|
||||||
|
.Where(x => x.RequestTypeId == requestTypeId)
|
||||||
|
.OrderBy(x => x.Email)
|
||||||
|
.Select(x => x.Email)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<string>>(emails);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeAccessDto>> CreateAsync(
|
||||||
|
CreateRequestTypeAccessRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken))
|
||||||
|
return Result.Fail<RequestTypeAccessDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||||
|
|
||||||
|
var email = request.Email.Trim();
|
||||||
|
var exists = await db.RequestTypeAccesses.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.RequestTypeId == request.RequestTypeId && x.Email == email, cancellationToken);
|
||||||
|
if (exists)
|
||||||
|
return Result.Fail<RequestTypeAccessDto>(
|
||||||
|
new ConflictError($"У {email} уже есть доступ к этому типу заявок."));
|
||||||
|
|
||||||
|
var entity = new RequestTypeAccess { RequestTypeId = request.RequestTypeId, Email = email };
|
||||||
|
db.RequestTypeAccesses.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Доступ к типу заявки {RequestTypeId} выдан {Email} пользователем {Author}",
|
||||||
|
entity.RequestTypeId, entity.Email, currentUser.Email);
|
||||||
|
|
||||||
|
var listsResult = await partitions.ListAsync(entity.RequestTypeId, cancellationToken);
|
||||||
|
var lists = listsResult.IsSuccess ? listsResult.Value : Array.Empty<RequestListPartitionDto>();
|
||||||
|
return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email, lists));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypeAccesses.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Доступ к типу заявки", id));
|
||||||
|
|
||||||
|
db.RequestTypeAccesses.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Доступ к типу заявки {RequestTypeId} для {Email} отозван пользователем {Author}",
|
||||||
|
entity.RequestTypeId, entity.Email, currentUser.Email);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses;
|
||||||
|
|
||||||
|
public sealed class CreateRequestTypeAccessRequestValidator : AbstractValidator<CreateRequestTypeAccessRequest>
|
||||||
|
{
|
||||||
|
public CreateRequestTypeAccessRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RequestTypeId).GreaterThan(0).WithMessage("Тип заявки обязателен.");
|
||||||
|
RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("Укажите корректный email.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Право одного пользователя видеть ВСЕ заявки одного типа в портале заявок
|
||||||
|
/// (а не только свои). У одного человека может быть по строке на каждый тип
|
||||||
|
/// заявок, к которому ему открыт полный доступ.
|
||||||
|
/// </summary>
|
||||||
|
public class RequestTypeAccess
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int RequestTypeId { get; set; }
|
||||||
|
public RequestType? RequestType { get; set; }
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||||
|
public DbSet<RequestTypeAccess> RequestTypeAccesses => Set<RequestTypeAccess>();
|
||||||
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
|
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
|
||||||
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||||
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||||
@@ -35,6 +36,15 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RequestTypeAccess>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||||
|
e.HasOne(x => x.RequestType).WithMany().HasForeignKey(x => x.RequestTypeId);
|
||||||
|
// Одна и та же пара тип+email не должна выдаваться дважды.
|
||||||
|
e.HasIndex(x => new { x.RequestTypeId, x.Email }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RequestListPartition>(e =>
|
modelBuilder.Entity<RequestListPartition>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
|
|||||||
+411
@@ -0,0 +1,411 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||||
|
[Migration("20260925092352_AddRequestTypeAccess")]
|
||||||
|
partial class AddRequestTypeAccess
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<int>("Number")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("number");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_list_partitions");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "Number")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
|
||||||
|
|
||||||
|
b.ToTable("request_list_partitions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool?>("AskOverdueReason")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("list_partitioning_started_at");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("PageUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("page_url");
|
||||||
|
|
||||||
|
b.Property<string>("Section")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("section");
|
||||||
|
|
||||||
|
b.Property<string>("SiteUrl")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("site_url");
|
||||||
|
|
||||||
|
b.Property<int?>("SlaHours")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sla_hours");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_types");
|
||||||
|
|
||||||
|
b.ToTable("request_types", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_type_accesses");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "Email")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_request_type_accesses_request_type_id_email");
|
||||||
|
|
||||||
|
b.ToTable("request_type_accesses", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("ByProject")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("by_project");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CompanyId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("company_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DepartmentId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("department_id");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<bool>("RequiresControl")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("requires_control");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_template_routes");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||||
|
|
||||||
|
b.ToTable("template_routes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
|
b.Property<bool?>("AskOverdueReason")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
|
b.Property<bool>("AutoApprove")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("auto_approve");
|
||||||
|
|
||||||
|
b.Property<string>("ConditionsLogic")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)")
|
||||||
|
.HasColumnName("conditions_logic");
|
||||||
|
|
||||||
|
b.Property<string>("ExecutorEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
|
b.Property<string>("HeadType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("head_type");
|
||||||
|
|
||||||
|
b.Property<int>("OrderNo")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
|
b.Property<string>("Position")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("position");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectRole")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("project_role");
|
||||||
|
|
||||||
|
b.Property<int>("RouteId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
|
b.Property<int?>("SlaHours")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sla_hours");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_template_route_steps");
|
||||||
|
|
||||||
|
b.HasIndex("RouteId", "OrderNo")
|
||||||
|
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||||
|
|
||||||
|
b.ToTable("template_route_steps", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("FieldName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("field_name");
|
||||||
|
|
||||||
|
b.Property<string>("Operation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<int>("OrderNo")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
|
b.Property<int>("StepId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("step_id");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_template_route_step_conditions");
|
||||||
|
|
||||||
|
b.HasIndex("StepId", "OrderNo")
|
||||||
|
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
|
||||||
|
|
||||||
|
b.ToTable("template_route_step_conditions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("OptionName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("option_name");
|
||||||
|
|
||||||
|
b.Property<byte[]>("FileData")
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("file_data");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("OptionName")
|
||||||
|
.HasName("pk_settings");
|
||||||
|
|
||||||
|
b.ToTable("settings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany("ListPartitions")
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_request_type_accesses_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany("Routes")
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||||
|
.WithMany("Steps")
|
||||||
|
.HasForeignKey("RouteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||||
|
|
||||||
|
b.Navigation("Route");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
|
||||||
|
.WithMany("Conditions")
|
||||||
|
.HasForeignKey("StepId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
|
||||||
|
|
||||||
|
b.Navigation("Step");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ListPartitions");
|
||||||
|
|
||||||
|
b.Navigation("Routes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Steps");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Conditions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRequestTypeAccess : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "request_type_accesses",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
request_type_id = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_request_type_accesses", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_request_type_accesses_request_types_request_type_id",
|
||||||
|
column: x => x.request_type_id,
|
||||||
|
principalTable: "request_types",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_request_type_accesses_request_type_id_email",
|
||||||
|
table: "request_type_accesses",
|
||||||
|
columns: new[] { "request_type_id", "email" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "request_type_accesses");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -119,6 +119,35 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_type_accesses");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "Email")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_request_type_accesses_request_type_id_email");
|
||||||
|
|
||||||
|
b.ToTable("request_type_accesses", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -309,6 +338,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("RequestType");
|
b.Navigation("RequestType");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_request_type_accesses_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { texts } from '../../texts';
|
|||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||||
|
import { RequestTypeAccessScreen } from '../screens/RequestTypeAccessScreen';
|
||||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||||
@@ -27,6 +28,7 @@ import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
|||||||
const NAV_ITEMS: ISidebarItem[] = [
|
const NAV_ITEMS: ISidebarItem[] = [
|
||||||
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
||||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||||
|
{ id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' },
|
||||||
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||||
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||||
@@ -147,6 +149,14 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||||
|
{route.tab === 'requestTypeAccess' && (
|
||||||
|
<RequestTypeAccessScreen
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
initialFilterRequestTypeId={route.accessFilterRequestTypeId}
|
||||||
|
initialFilterEmail={route.accessFilterEmail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{route.tab === 'routes' && !route.routeId && (
|
{route.tab === 'routes' && !route.routeId && (
|
||||||
<RoutesScreen
|
<RoutesScreen
|
||||||
api={api}
|
api={api}
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser';
|
export type ScreenKey =
|
||||||
|
| 'dashboard'
|
||||||
|
| 'requestTypes'
|
||||||
|
| 'requestTypeAccess'
|
||||||
|
| 'routes'
|
||||||
|
| 'routeSteps'
|
||||||
|
| 'resolve'
|
||||||
|
| 'manageRequests'
|
||||||
|
| 'manageByUser';
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterField {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 320px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './RequestTypeAccessScreen.module.scss';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
|
import { useRequestTypeAccess } from '../../hooks/useRequestTypeAccess';
|
||||||
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
|
import { replaceRoute } from '../../routing';
|
||||||
|
import { RequestTypeAccess } from '../../models';
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
Alert,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
FormGrid,
|
||||||
|
FormModal,
|
||||||
|
IconButton,
|
||||||
|
PageHeader,
|
||||||
|
PrimaryCell,
|
||||||
|
SearchableSelect,
|
||||||
|
Select
|
||||||
|
} from '../ui';
|
||||||
|
|
||||||
|
export interface IRequestTypeAccessScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
|
/** Фильтры из ссылки (aaacctype/aaaccemail) — перекрывают сохранённые, если заданы. */
|
||||||
|
initialFilterRequestTypeId?: string;
|
||||||
|
initialFilterEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтры переживают перезагрузку — тот же приём, что и ROUTES_FILTER_STORAGE_KEY в RoutesScreen.
|
||||||
|
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
|
||||||
|
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
|
||||||
|
|
||||||
|
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
|
||||||
|
api,
|
||||||
|
dictionary,
|
||||||
|
initialFilterRequestTypeId,
|
||||||
|
initialFilterEmail
|
||||||
|
}) => {
|
||||||
|
const requestTypes = useRequestTypes(api);
|
||||||
|
const access = useRequestTypeAccess(api);
|
||||||
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
|
||||||
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||||
|
const [email, setEmail] = React.useState('');
|
||||||
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
|
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
||||||
|
const [filterEmail, setFilterEmail] = useLocalStorageState(ACCESS_FILTER_EMAIL_STORAGE_KEY, '');
|
||||||
|
|
||||||
|
// Ссылка с фильтром сильнее сохранённого значения — только на монтировании
|
||||||
|
// (см. тот же приём в RoutesScreen).
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialFilterEmail) setFilterEmail(initialFilterEmail);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Держим адрес синхронным с фильтрами — отфильтрованную таблицу можно
|
||||||
|
// передать ссылкой. replaceRoute, а не navigate: смена фильтра не должна
|
||||||
|
// плодить записи в истории браузера.
|
||||||
|
React.useEffect(() => {
|
||||||
|
replaceRoute({ tab: 'requestTypeAccess', accessFilterRequestTypeId: filterRequestTypeId, accessFilterEmail: filterEmail });
|
||||||
|
}, [filterRequestTypeId, filterEmail]);
|
||||||
|
|
||||||
|
const deleteTarget = useDeleteTarget<RequestTypeAccess>(item => api.requestTypeAccess.remove(item.id), access.reload);
|
||||||
|
|
||||||
|
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||||
|
|
||||||
|
const filteredItems = access.items.filter(item => {
|
||||||
|
if (filterRequestTypeId && String(item.requestTypeId) !== filterRequestTypeId) return false;
|
||||||
|
if (filterEmail && item.email.toLowerCase() !== filterEmail.toLowerCase()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ
|
||||||
|
// здесь настраивает только видимость в UI approval-route, но не в самом
|
||||||
|
// SharePoint (поиск по порталу отфильтрует чужие заявки правами списка).
|
||||||
|
const formatSharePointLists = (item: RequestTypeAccess): string =>
|
||||||
|
item.sharePointLists.length === 0
|
||||||
|
? texts.common.empty
|
||||||
|
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
setRequestTypeId('');
|
||||||
|
setEmail('');
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = (): void => {
|
||||||
|
if (!requestTypeId) {
|
||||||
|
submit.fail(texts.requestTypeAccess.validationRequestType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!email.trim()) {
|
||||||
|
submit.fail(texts.requestTypeAccess.validationEmail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit.run(() =>
|
||||||
|
api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email: email.trim() }).then(() => {
|
||||||
|
setShowForm(false);
|
||||||
|
access.reload();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypeAccess.breadcrumb }]} />
|
||||||
|
<PageHeader
|
||||||
|
icon="people"
|
||||||
|
subtitle={texts.requestTypeAccess.subtitle}
|
||||||
|
title={texts.requestTypeAccess.title}
|
||||||
|
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypeAccess.add}</Button>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<div className={styles.filterField}>
|
||||||
|
<Field label={texts.requestTypeAccess.filterRequestTypeLabel}>
|
||||||
|
<Select
|
||||||
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
|
placeholder={texts.requestTypeAccess.filterRequestTypeAll}
|
||||||
|
value={filterRequestTypeId}
|
||||||
|
onChange={e => setFilterRequestTypeId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className={styles.filterField}>
|
||||||
|
<Field label={texts.requestTypeAccess.filterEmailLabel}>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employees.items.map(toEmployeeOption)}
|
||||||
|
value={filterEmail}
|
||||||
|
loading={employees.isLoading}
|
||||||
|
allowClear
|
||||||
|
clearLabel={texts.requestTypeAccess.filterEmailAll}
|
||||||
|
placeholder={texts.requestTypeAccess.filterEmailPlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={setFilterEmail}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{access.error && <Alert kind="err">{access.error}</Alert>}
|
||||||
|
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'requestType', header: texts.requestTypeAccess.columnRequestType, render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> },
|
||||||
|
{ key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email },
|
||||||
|
{ key: 'sharePointLists', header: texts.requestTypeAccess.columnSharePointLists, render: formatSharePointLists },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
width: '100px',
|
||||||
|
align: 'right',
|
||||||
|
sticky: true,
|
||||||
|
render: r => (
|
||||||
|
<ActionsCell>
|
||||||
|
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||||
|
</ActionsCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
rows={filteredItems}
|
||||||
|
keyOf={r => r.id}
|
||||||
|
emptyMessage={
|
||||||
|
access.isLoading
|
||||||
|
? texts.common.loading
|
||||||
|
: access.items.length > 0 && filteredItems.length === 0
|
||||||
|
? texts.requestTypeAccess.emptyFiltered
|
||||||
|
: texts.requestTypeAccess.emptyList
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<FormModal
|
||||||
|
title={texts.requestTypeAccess.createTitle}
|
||||||
|
error={submit.error}
|
||||||
|
isSaving={submit.isBusy}
|
||||||
|
onSubmit={submitForm}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
>
|
||||||
|
<FormGrid>
|
||||||
|
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
|
||||||
|
<Select
|
||||||
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
|
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
|
||||||
|
value={requestTypeId}
|
||||||
|
onChange={e => setRequestTypeId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employees.items.map(toEmployeeOption)}
|
||||||
|
value={email}
|
||||||
|
loading={employees.isLoading}
|
||||||
|
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={setEmail}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</FormModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteTarget.target && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={texts.requestTypeAccess.deleteTitle}
|
||||||
|
text={texts.requestTypeAccess.deleteText(deleteTarget.target.email, requestTypeName(deleteTarget.target.requestTypeId))}
|
||||||
|
confirmLabel={texts.common.remove}
|
||||||
|
busy={deleteTarget.isBusy}
|
||||||
|
onCancel={deleteTarget.cancel}
|
||||||
|
onConfirm={deleteTarget.confirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { RequestTypeAccess } from '../models';
|
||||||
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
|
import { AsyncList, useAsyncList } from './useAsyncList';
|
||||||
|
|
||||||
|
/** Реестр доступа «видеть все заявки типа» — целиком, по всем типам заявок. */
|
||||||
|
export function useRequestTypeAccess(api: ApprovalRouteApi): AsyncList<RequestTypeAccess> {
|
||||||
|
const load = React.useCallback(() => api.requestTypeAccess.list(), [api]);
|
||||||
|
return useAsyncList(load);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
export * from './enums';
|
export * from './enums';
|
||||||
export * from './dictionaries';
|
export * from './dictionaries';
|
||||||
export * from './requestTypes';
|
export * from './requestTypes';
|
||||||
|
export * from './requestTypeAccess';
|
||||||
export * from './requestTypeLists';
|
export * from './requestTypeLists';
|
||||||
export * from './templateRoutes';
|
export * from './templateRoutes';
|
||||||
export * from './templateRouteSteps';
|
export * from './templateRouteSteps';
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// DTO эндпоинта api/request-type-access (Application/RequestTypeAccesses/*.cs).
|
||||||
|
import { RequestListPartition } from './requestTypeLists';
|
||||||
|
|
||||||
|
export interface RequestTypeAccess {
|
||||||
|
id: number;
|
||||||
|
requestTypeId: number;
|
||||||
|
email: string;
|
||||||
|
/**
|
||||||
|
* Вся серия SharePoint-списков данных типа заявки (архив + текущая партиция) —
|
||||||
|
* именно на них нужно вручную выдать email'у право чтения, иначе поиск по
|
||||||
|
* порталу продолжит отдавать только его собственные заявки: этот реестр
|
||||||
|
* настраивает видимость в UI approval-route, а не права SharePoint.
|
||||||
|
*/
|
||||||
|
sharePointLists: RequestListPartition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRequestTypeAccessInput {
|
||||||
|
requestTypeId: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
@@ -69,6 +69,14 @@ export interface IAdminRoute {
|
|||||||
* к тому же отфильтрованному списку, а не сбросить фильтр.
|
* к тому же отфильтрованному списку, а не сбросить фильтр.
|
||||||
*/
|
*/
|
||||||
routesFilter?: string;
|
routesFilter?: string;
|
||||||
|
/**
|
||||||
|
* Фильтры таблицы «Доступ к заявкам» (id типа как строка, email) — только при
|
||||||
|
* tab='requestTypeAccess'. Та же логика, что и у routesFilter: живут в адресе,
|
||||||
|
* чтобы отфильтрованную ссылку можно было передать и чтобы фильтр не терялся
|
||||||
|
* при возврате на вкладку.
|
||||||
|
*/
|
||||||
|
accessFilterRequestTypeId?: string;
|
||||||
|
accessFilterEmail?: string;
|
||||||
/** Задан только при tab='resolve' и непустом requestTypeId. */
|
/** Задан только при tab='resolve' и непустом requestTypeId. */
|
||||||
resolve?: IResolveRouteParams;
|
resolve?: IResolveRouteParams;
|
||||||
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
||||||
@@ -81,10 +89,11 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
|||||||
|
|
||||||
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
||||||
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
||||||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
|
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
|
||||||
|
|
||||||
const PARAM_KEYS = [
|
const PARAM_KEYS = [
|
||||||
'aatab', 'aaroute', 'aarfilter',
|
'aatab', 'aaroute', 'aarfilter',
|
||||||
|
'aaacctype', 'aaaccemail',
|
||||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||||
'aamtype', 'aamreq', 'aamlist',
|
'aamtype', 'aamreq', 'aamlist',
|
||||||
'aabuser', 'aabtarget'
|
'aabuser', 'aabtarget'
|
||||||
@@ -146,6 +155,8 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
|||||||
tab,
|
tab,
|
||||||
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
||||||
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
||||||
|
accessFilterRequestTypeId: (tab === 'requestTypeAccess' ? params.get('aaacctype') : null) || undefined,
|
||||||
|
accessFilterEmail: (tab === 'requestTypeAccess' ? params.get('aaaccemail') : null) || undefined,
|
||||||
resolve,
|
resolve,
|
||||||
manage,
|
manage,
|
||||||
manageByUser: byUserEmail
|
manageByUser: byUserEmail
|
||||||
@@ -167,6 +178,11 @@ function buildQuery(route: IAdminRoute): string {
|
|||||||
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
|
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.tab === 'requestTypeAccess') {
|
||||||
|
if (route.accessFilterRequestTypeId) params.set('aaacctype', route.accessFilterRequestTypeId);
|
||||||
|
if (route.accessFilterEmail) params.set('aaaccemail', route.accessFilterEmail);
|
||||||
|
}
|
||||||
|
|
||||||
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
|
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
|
||||||
params.set('aartype', route.resolve.requestTypeId);
|
params.set('aartype', route.resolve.requestTypeId);
|
||||||
if (route.resolve.userEmail) params.set('aauser', route.resolve.userEmail);
|
if (route.resolve.userEmail) params.set('aauser', route.resolve.userEmail);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IHttpClient } from './httpClient';
|
import { IHttpClient } from './httpClient';
|
||||||
import {
|
import {
|
||||||
|
CreateRequestTypeAccessInput,
|
||||||
CreateRequestTypeInput,
|
CreateRequestTypeInput,
|
||||||
CreateTemplateRouteInput,
|
CreateTemplateRouteInput,
|
||||||
CreateTemplateRouteStepInput,
|
CreateTemplateRouteStepInput,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
PatchTemplateRouteStepInput,
|
PatchTemplateRouteStepInput,
|
||||||
RequestListPartition,
|
RequestListPartition,
|
||||||
RequestType,
|
RequestType,
|
||||||
|
RequestTypeAccess,
|
||||||
RequestTypeListSeries,
|
RequestTypeListSeries,
|
||||||
ResolveTemplateRouteQuery,
|
ResolveTemplateRouteQuery,
|
||||||
ResolveTemplateRouteResult,
|
ResolveTemplateRouteResult,
|
||||||
@@ -80,6 +82,21 @@ export class ApprovalRouteApi {
|
|||||||
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
|
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Доступ «видеть все заявки типа» (Application/RequestTypeAccesses/*.cs) —
|
||||||
|
// тот же список email по типу, что и ручка для Power Automate
|
||||||
|
// (GET .../{requestTypeId}/emails), только здесь читается ВЕСЬ реестр разом
|
||||||
|
// (requestTypeId не передан) — админке нужна одна таблица по всем типам.
|
||||||
|
public readonly requestTypeAccess = {
|
||||||
|
list: (requestTypeId?: number): Promise<RequestTypeAccess[]> =>
|
||||||
|
this.http.get<RequestTypeAccess[]>('/api/request-type-access', { requestTypeId }),
|
||||||
|
|
||||||
|
create: (body: CreateRequestTypeAccessInput): Promise<RequestTypeAccess> =>
|
||||||
|
this.http.post<RequestTypeAccess>('/api/request-type-access', body),
|
||||||
|
|
||||||
|
remove: (id: number): Promise<void> =>
|
||||||
|
this.http.delete(`/api/request-type-access/${id}`)
|
||||||
|
};
|
||||||
|
|
||||||
public readonly templateRoutes = {
|
public readonly templateRoutes = {
|
||||||
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
|
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
|
||||||
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
|
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const texts = {
|
|||||||
title: 'Панель настройки маршрутов',
|
title: 'Панель настройки маршрутов',
|
||||||
dashboard: 'Дашборд',
|
dashboard: 'Дашборд',
|
||||||
requestTypes: 'Типы заявок',
|
requestTypes: 'Типы заявок',
|
||||||
|
requestTypeAccess: 'Доступ к заявкам',
|
||||||
routes: 'Маршруты',
|
routes: 'Маршруты',
|
||||||
resolve: 'Проверка маршрута',
|
resolve: 'Проверка маршрута',
|
||||||
manageRequests: 'Редактирование заявок',
|
manageRequests: 'Редактирование заявок',
|
||||||
@@ -178,6 +179,33 @@ export const texts = {
|
|||||||
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
|
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
|
||||||
},
|
},
|
||||||
|
|
||||||
|
requestTypeAccess: {
|
||||||
|
breadcrumb: 'Доступ к заявкам',
|
||||||
|
subtitle: 'Кто видит все заявки типа, а не только свои',
|
||||||
|
title: 'Доступ к заявкам',
|
||||||
|
add: 'Выдать доступ',
|
||||||
|
emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.',
|
||||||
|
emptyFiltered: 'Ничего не найдено по заданным фильтрам.',
|
||||||
|
filterRequestTypeLabel: 'Фильтр по типу заявки',
|
||||||
|
filterRequestTypeAll: 'Все типы заявок',
|
||||||
|
filterEmailLabel: 'Фильтр по сотруднику',
|
||||||
|
filterEmailAll: 'Все сотрудники',
|
||||||
|
filterEmailPlaceholder: 'Найдите по ФИО или email',
|
||||||
|
columnRequestType: 'Тип заявки',
|
||||||
|
columnEmail: 'Сотрудник',
|
||||||
|
columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)',
|
||||||
|
createTitle: 'Выдать доступ',
|
||||||
|
requestTypeLabel: 'Тип заявки',
|
||||||
|
requestTypePlaceholder: 'Выберите тип заявки',
|
||||||
|
emailLabel: 'Сотрудник',
|
||||||
|
emailPlaceholder: 'Найдите по ФИО или email',
|
||||||
|
validationRequestType: 'Выберите тип заявки.',
|
||||||
|
validationEmail: 'Выберите сотрудника.',
|
||||||
|
deleteTitle: 'Отозвать доступ?',
|
||||||
|
deleteText: (email: string, requestTypeName: string): string =>
|
||||||
|
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
|
||||||
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
breadcrumb: 'Маршруты согласования',
|
breadcrumb: 'Маршруты согласования',
|
||||||
subtitle: 'Настройка согласований',
|
subtitle: 'Настройка согласований',
|
||||||
|
|||||||
Reference in New Issue
Block a user