Feature/request type access (#23)
Tests / frontend-test (push) Successful in 49s
Tests / backend-test (push) Successful in 1m38s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/23
This commit was merged in pull request #23.
This commit is contained in:
2026-09-25 11:48:02 +00:00
parent 1bf8d4ca11
commit a8bfd9f456
20 changed files with 1067 additions and 2 deletions
@@ -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);
@@ -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();
}
}
@@ -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);
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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';
@@ -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;
}
+17 -1
View File
@@ -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: 'Настройка согласований',