From ea2599732cce4fa8d08c29b5deda8ae3d2e29ab6 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 31 Jul 2026 10:46:53 +0500 Subject: [PATCH 01/14] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=83=D1=8E=20=D0=B1=D0=B0=D0=B7=D0=B5=D0=B9=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/Tnsu.ApprovalRoute.Api/appsettings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json index 12fe82b..eac5c14 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json @@ -6,9 +6,10 @@ } }, "ConnectionStrings": { - "Postgres": "Host=db-master.tnsu.kz;Database=tnsu_approval_route;Username=dbuser;Password=p}{6{Pa]{JQwP1H" + "Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" }, "Entra": { + "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "Audience": "9232d9e2-9807-4753-808f-07250e5474b0" }, -- 2.34.1 From fdd3787e430e2f10c47c63df45e483303faf819b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 31 Jul 2026 11:54:16 +0500 Subject: [PATCH 02/14] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequestTypes/RequestTypeDto.cs | 8 +- .../RequestTypes/RequestTypeService.cs | 16 +- .../RequestTypes/RequestTypeValidators.cs | 4 + .../TemplateRouteStepDto.cs | 18 +- .../TemplateRouteStepService.cs | 53 ++++- .../TemplateRouteStepValidators.cs | 17 ++ .../Entities/RequestType.cs | 2 + .../Entities/TemplateRouteStep.cs | 4 + .../Enums/ComparisonOperator.cs | 9 + .../Persistence/ApprovalRouteDbContext.cs | 5 + ...eSectionAndStepConditionFields.Designer.cs | 218 ++++++++++++++++++ ...peListNameSectionAndStepConditionFields.cs | 74 ++++++ .../ApprovalRouteDbContextModelSnapshot.cs | 26 +++ .../RequestTypeServiceTests.cs | 18 +- .../TemplateRouteServiceTests.cs | 32 +-- .../TemplateRouteStepServiceTests.cs | 114 +++++++-- 16 files changed, 556 insertions(+), 62 deletions(-) create mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs index 50cdce7..96e0e5e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -1,9 +1,9 @@ namespace Tnsu.ApprovalRoute.Application.RequestTypes; -public sealed record RequestTypeDto(int Id, string Name, string PageUrl); +public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListName, string Section); -public sealed record CreateRequestTypeRequest(string Name, string PageUrl); +public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListName, string Section); -public sealed record UpdateRequestTypeRequest(string Name, string PageUrl); +public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListName, string Section); -public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl); +public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListName, string? Section); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 481c5d9..999ccf0 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) { var items = await db.RequestTypes.AsNoTracking() .OrderBy(x => x.Name) - .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl)) + .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListName, x.Section)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -29,7 +29,13 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) public async Task> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) { - var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl }; + var entity = new RequestType + { + Name = request.Name, + PageUrl = request.PageUrl, + ListName = request.ListName, + Section = request.Section + }; db.RequestTypes.Add(entity); await db.SaveChangesAsync(cancellationToken); @@ -44,6 +50,8 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) entity.Name = request.Name; entity.PageUrl = request.PageUrl; + entity.ListName = request.ListName; + entity.Section = request.Section; await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); @@ -57,6 +65,8 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) entity.Name = request.Name ?? entity.Name; entity.PageUrl = request.PageUrl ?? entity.PageUrl; + entity.ListName = request.ListName ?? entity.ListName; + entity.Section = request.Section ?? entity.Section; await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); @@ -74,5 +84,5 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(); } - private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl); + private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListName, e.Section); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs index 51946db..5a14f9c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -8,6 +8,7 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); + RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); } } @@ -17,6 +18,7 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); + RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); } } @@ -28,5 +30,7 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым.")); When(x => x.PageUrl is not null, () => RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым.")); + When(x => x.Section is not null, () => + RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым.")); } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index ca3074e..200c375 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -12,6 +12,9 @@ public sealed record TemplateRouteStepDto( RouteStepStatus Status, int RouteId, bool AllowReassignToOtherApprovers, + string? FieldName, + ComparisonOperator? Operation, + string? Value, string? Warning = null); public sealed record CreateTemplateRouteStepRequest( @@ -21,7 +24,10 @@ public sealed record CreateTemplateRouteStepRequest( ProjectRole? ProjectRole, HeadType? HeadType, RouteStepStatus Status, - bool AllowReassignToOtherApprovers); + bool AllowReassignToOtherApprovers, + string? FieldName, + ComparisonOperator? Operation, + string? Value); public sealed record UpdateTemplateRouteStepRequest( int OrderNo, @@ -30,7 +36,10 @@ public sealed record UpdateTemplateRouteStepRequest( ProjectRole? ProjectRole, HeadType? HeadType, RouteStepStatus Status, - bool AllowReassignToOtherApprovers); + bool AllowReassignToOtherApprovers, + string? FieldName, + ComparisonOperator? Operation, + string? Value); public sealed record PatchTemplateRouteStepRequest( int? OrderNo, @@ -39,4 +48,7 @@ public sealed record PatchTemplateRouteStepRequest( ProjectRole? ProjectRole, HeadType? HeadType, RouteStepStatus? Status, - bool? AllowReassignToOtherApprovers); + bool? AllowReassignToOtherApprovers, + string? FieldName, + ComparisonOperator? Operation, + string? Value); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 9e100bb..4525edf 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -15,7 +15,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) .Where(x => x.RouteId == routeId) .OrderBy(x => x.OrderNo) .Select(x => new TemplateRouteStepDto( - x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers)) + x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, + x.FieldName, x.Operation, x.Value)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -33,7 +34,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) public async Task> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) { - var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); @@ -41,6 +42,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + + if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name).")); + var duplicate = await HasDuplicateAsync( route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken); if (duplicate) @@ -56,7 +65,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) ProjectRole = r.ProjectRole, HeadType = r.HeadType, Status = r.Status, - AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers + AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers, + FieldName = r.FieldName, + Operation = r.Operation, + Value = r.Value }; db.TemplateRouteSteps.Add(entity); await db.SaveChangesAsync(cancellationToken); @@ -67,7 +79,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) public async Task> UpdateAsync( int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) { - var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); @@ -79,6 +91,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + + if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name).")); + var duplicate = await HasDuplicateAsync( route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken); if (duplicate) @@ -92,6 +112,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.HeadType = r.HeadType; entity.Status = r.Status; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; + entity.FieldName = r.FieldName; + entity.Operation = r.Operation; + entity.Value = r.Value; await db.SaveChangesAsync(cancellationToken); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); @@ -100,7 +123,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) public async Task> PatchAsync( int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken) { - var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); @@ -113,6 +136,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) var projectRole = r.ProjectRole ?? entity.ProjectRole; var headType = r.HeadType ?? entity.HeadType; var status = r.Status ?? entity.Status; + var fieldName = r.FieldName ?? entity.FieldName; + var operation = r.Operation ?? entity.Operation; + var value = r.Value ?? entity.Value; if (status == RouteStepStatus.None) return Result.Fail(new ValidationError("Статус обязателен: Approval или Execution.")); @@ -125,6 +151,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Fail(new ValidationError( "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); + if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + + if (RequiresListName(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name).")); + var duplicate = await HasDuplicateAsync( route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken); if (duplicate) @@ -138,6 +172,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.HeadType = headType; entity.Status = status; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; + entity.FieldName = fieldName; + entity.Operation = operation; + entity.Value = value; await db.SaveChangesAsync(cancellationToken); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); @@ -155,6 +192,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok(); } + private static bool RequiresListName(string? fieldName, ComparisonOperator? operation, string? value) => + TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0; + private Task HasDuplicateAsync( int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) => @@ -184,5 +224,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) } private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( - e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers); + e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers, + e.FieldName, e.Operation, e.Value); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 89357a5..1c4ac4b 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -11,6 +11,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< RuleFor(x => x) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); + RuleFor(x => x) + .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) + .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); } } @@ -22,6 +25,9 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< RuleFor(x => x) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); + RuleFor(x => x) + .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) + .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); } } @@ -32,4 +38,15 @@ internal static class TemplateRouteStepFields (string.IsNullOrWhiteSpace(position) ? 0 : 1) + (projectRole is null or ProjectRole.None ? 0 : 1) + (headType is null or HeadType.None ? 0 : 1); + + public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) => + (string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) + + (operation is null or ComparisonOperator.None ? 0 : 1) + + (string.IsNullOrWhiteSpace(value) ? 0 : 1); + + public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value) + { + var count = CountConditionFilled(fieldName, operation, value); + return count == 0 || count == 3; + } } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index d86af83..dfcb7d6 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -5,6 +5,8 @@ public class RequestType public int Id { get; set; } public string Name { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty; + public string? ListName { get; set; } + public string Section { get; set; } = string.Empty; public ICollection Routes { get; set; } = new List(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 9901b07..ce5a6be 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -16,5 +16,9 @@ public class TemplateRouteStep public int RouteId { get; set; } public bool AllowReassignToOtherApprovers { get; set; } + public string? FieldName { get; set; } + public ComparisonOperator? Operation { get; set; } + public string? Value { get; set; } + public TemplateRoute? Route { get; set; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs new file mode 100644 index 0000000..7bfbdce --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs @@ -0,0 +1,9 @@ +namespace Tnsu.ApprovalRoute.Domain.Enums; + +public enum ComparisonOperator +{ + None, + GreaterThan, + LessThan, + Equal +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 2c84977..401fa69 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -16,6 +16,8 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.HasKey(x => x.Id); e.Property(x => x.Name).HasMaxLength(256).IsRequired(); e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); + e.Property(x => x.ListName).HasMaxLength(256); + e.Property(x => x.Section).HasMaxLength(256).IsRequired(); }); modelBuilder.Entity(e => @@ -34,6 +36,9 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.ProjectRole).HasConversion().HasMaxLength(32); e.Property(x => x.HeadType).HasConversion().HasMaxLength(32); e.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); + e.Property(x => x.FieldName).HasMaxLength(128); + e.Property(x => x.Operation).HasConversion().HasMaxLength(32); + e.Property(x => x.Value).HasMaxLength(256); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasIndex(x => new { x.RouteId, x.OrderNo }); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs new file mode 100644 index 0000000..d62004d --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs @@ -0,0 +1,218 @@ +// +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("20260731062407_AddRequestTypeListNameSectionAndStepConditionFields")] + partial class AddRequestTypeListNameSectionAndStepConditionFields + { + /// + 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.RequestType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ListName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("list_name"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("page_url"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.HasKey("Id") + .HasName("pk_request_types"); + + b.ToTable("request_types", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByProject") + .HasColumnType("boolean") + .HasColumnName("by_project"); + + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); + + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("RequiresControl") + .HasColumnType("boolean") + .HasColumnName("requires_control"); + + b.Property("RouteType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("route_type"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowReassignToOtherApprovers") + .HasColumnType("boolean") + .HasColumnName("allow_reassign_to_other_approvers"); + + b.Property("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("FieldName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("Operation") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("Position") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("position"); + + b.Property("ProjectRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("project_role"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + 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.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.RequestType", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs new file mode 100644 index 0000000..f349663 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRequestTypeListNameSectionAndStepConditionFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "field_name", + table: "template_route_steps", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "operation", + table: "template_route_steps", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "value", + table: "template_route_steps", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "list_name", + table: "request_types", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "section", + table: "request_types", + type: "character varying(256)", + maxLength: 256, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "field_name", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "operation", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "value", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "list_name", + table: "request_types"); + + migrationBuilder.DropColumn( + name: "section", + table: "request_types"); + } + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 5b2cea2..fed778a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -31,6 +31,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ListName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("list_name"); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -43,6 +48,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(512)") .HasColumnName("page_url"); + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + b.HasKey("Id") .HasName("pk_request_types"); @@ -112,11 +123,21 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("executor_email"); + b.Property("FieldName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + b.Property("HeadType") .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("head_type"); + b.Property("Operation") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + b.Property("OrderNo") .HasColumnType("integer") .HasColumnName("order_no"); @@ -141,6 +162,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(32)") .HasColumnName("status"); + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + b.HasKey("Id") .HasName("pk_template_route_steps"); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index 30ff46f..e46fa47 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -13,7 +13,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() { // Action - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation", null, "General"), CancellationToken.None); // Assert created.IsSuccess.ShouldBeTrue(); @@ -43,11 +43,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task UpdateAsync_ChangesFields() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old", null, "General"), CancellationToken.None); // Action var updated = await _service.UpdateAsync( - created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None); + created.Value.Id, new UpdateRequestTypeRequest("New name", "/new", null, "General"), CancellationToken.None); // Assert updated.IsSuccess.ShouldBeTrue(); @@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.UpdateAsync( - 999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); + 999999, new UpdateRequestTypeRequest("X", "/x", null, "General"), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -70,11 +70,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task PatchAsync_UpdatesOnlyProvidedFields() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original", null, "General"), CancellationToken.None); // Action var patched = await _service.PatchAsync( - created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None); + created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListName: null, Section: null), CancellationToken.None); // Assert patched.IsSuccess.ShouldBeTrue(); @@ -87,7 +87,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.PatchAsync( - 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None); + 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListName: null, Section: null), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -97,7 +97,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task DeleteAsync_RemovesEntity() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp", null, "General"), CancellationToken.None); // Action var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); @@ -113,7 +113,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task ListAsync_IncludesCreatedEntity() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed", null, "General"), CancellationToken.None); // Action var list = await _service.ListAsync(CancellationToken.None); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 7bf9c42..471ce96 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -38,7 +38,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture CreateRequestTypeAsync(string name) => - (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id; + (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, "General"), CancellationToken.None)).Value.Id; [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() @@ -163,7 +163,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -196,22 +196,22 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates", null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -240,7 +240,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -272,13 +272,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Position request", "/position-request"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Position request", "/position-request", null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -311,13 +311,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Chief request", "/chief-request"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Chief request", "/chief-request", null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -346,13 +346,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request", null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -379,7 +379,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -407,7 +407,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -435,7 +435,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 31daf65..8efbe4a 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -24,10 +24,10 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture(); } - private async Task CreateRouteAsync(string requestTypeName, bool byProject) + private async Task CreateRouteAsync(string requestTypeName, bool byProject, string? listName = null) { var requestTypeId = (await _requestTypeService.CreateAsync( - new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}", listName, "General"), CancellationToken.None)).Value.Id; var route = await _routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), @@ -42,7 +42,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture Date: Fri, 31 Jul 2026 12:51:48 +0500 Subject: [PATCH 03/14] =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequestTypes/RequestTypeDto.cs | 8 +- .../RequestTypes/RequestTypeService.cs | 24 ++++- .../RequestTypes/RequestTypeValidators.cs | 12 +++ .../Entities/RequestType.cs | 1 + .../Persistence/ApprovalRouteDbContext.cs | 1 + ...SectionAndStepConditionFields.Designer.cs} | 7 +- ...eListNameSectionAndStepConditionFields.cs} | 11 +++ .../ApprovalRouteDbContextModelSnapshot.cs | 5 + .../RequestTypeServiceTests.cs | 44 +++++++-- .../TemplateRouteServiceTests.cs | 10 +- .../TemplateRouteStepServiceTests.cs | 3 +- frontend/package-lock.json | 71 ++++++++++++++ frontend/package.json | 1 + .../components/ApprovalAdmin.tsx | 3 + .../approvalAdmin/components/app/AdminApp.tsx | 18 ++-- .../components/screens/RequestTypesScreen.tsx | 75 +++++++++++++- .../components/screens/RouteStepFormModal.tsx | 98 ++++++++++++++++++- .../components/screens/RouteStepsScreen.tsx | 12 ++- .../hooks/useSharePointFields.ts | 41 ++++++++ .../approvalAdmin/hooks/useSharePointLists.ts | 45 +++++++++ .../webparts/approvalAdmin/models/enums.ts | 7 ++ .../approvalAdmin/models/requestTypes.ts | 11 +++ .../models/templateRouteSteps.ts | 13 ++- .../approvalAdmin/services/sharePointLists.ts | 61 ++++++++++++ frontend/src/webparts/approvalAdmin/texts.ts | 36 ++++++- 25 files changed, 573 insertions(+), 45 deletions(-) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs => 20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs} (96%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs => 20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs} (86%) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useSharePointFields.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useSharePointLists.ts create mode 100644 frontend/src/webparts/approvalAdmin/services/sharePointLists.ts diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs index 96e0e5e..59b6536 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -1,9 +1,9 @@ namespace Tnsu.ApprovalRoute.Application.RequestTypes; -public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListName, string Section); +public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListName, string Section); +public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListName, string Section); +public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListName, string? Section); +public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListName, string? SiteUrl, string? Section); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 999ccf0..83aface 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) { var items = await db.RequestTypes.AsNoTracking() .OrderBy(x => x.Name) - .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListName, x.Section)) + .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListName, x.SiteUrl, x.Section)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -29,11 +29,16 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) public async Task> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) { + if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl, ListName = request.ListName, + SiteUrl = request.SiteUrl, Section = request.Section }; db.RequestTypes.Add(entity); @@ -48,9 +53,14 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Тип заявки", id)); + if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + entity.Name = request.Name; entity.PageUrl = request.PageUrl; entity.ListName = request.ListName; + entity.SiteUrl = request.SiteUrl; entity.Section = request.Section; await db.SaveChangesAsync(cancellationToken); @@ -63,9 +73,17 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Тип заявки", id)); + var listName = request.ListName ?? entity.ListName; + var siteUrl = request.SiteUrl ?? entity.SiteUrl; + + if (!RequestTypeFields.IsListReferenceValid(listName, siteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + entity.Name = request.Name ?? entity.Name; entity.PageUrl = request.PageUrl ?? entity.PageUrl; - entity.ListName = request.ListName ?? entity.ListName; + entity.ListName = listName; + entity.SiteUrl = siteUrl; entity.Section = request.Section ?? entity.Section; await db.SaveChangesAsync(cancellationToken); @@ -84,5 +102,5 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(); } - private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListName, e.Section); + private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListName, e.SiteUrl, e.Section); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs index 5a14f9c..33852e9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -9,6 +9,9 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); + RuleFor(x => x) + .Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl)) + .WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."); } } @@ -19,6 +22,9 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); + RuleFor(x => x) + .Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl)) + .WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."); } } @@ -34,3 +40,9 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator x.Section).NotEmpty().WithMessage("Раздел не может быть пустым.")); } } + +internal static class RequestTypeFields +{ + public static bool IsListReferenceValid(string? listName, string? siteUrl) => + string.IsNullOrWhiteSpace(listName) == string.IsNullOrWhiteSpace(siteUrl); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index dfcb7d6..5b9f8bf 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -6,6 +6,7 @@ public class RequestType public string Name { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty; public string? ListName { get; set; } + public string? SiteUrl { get; set; } public string Section { get; set; } = string.Empty; public ICollection Routes { get; set; } = new List(); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 401fa69..4f4bb5d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -17,6 +17,7 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.Name).HasMaxLength(256).IsRequired(); e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); e.Property(x => x.ListName).HasMaxLength(256); + e.Property(x => x.SiteUrl).HasMaxLength(512); e.Property(x => x.Section).HasMaxLength(256).IsRequired(); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs similarity index 96% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs index d62004d..a49cb0c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs @@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260731062407_AddRequestTypeListNameSectionAndStepConditionFields")] + [Migration("20260731073536_AddRequestTypeListNameSectionAndStepConditionFields")] partial class AddRequestTypeListNameSectionAndStepConditionFields { /// @@ -57,6 +57,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("section"); + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + b.HasKey("Id") .HasName("pk_request_types"); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs similarity index 86% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs index f349663..d6dfe6e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs @@ -45,6 +45,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations maxLength: 256, nullable: false, defaultValue: ""); + + migrationBuilder.AddColumn( + name: "site_url", + table: "request_types", + type: "character varying(512)", + maxLength: 512, + nullable: true); } /// @@ -69,6 +76,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations migrationBuilder.DropColumn( name: "section", table: "request_types"); + + migrationBuilder.DropColumn( + name: "site_url", + table: "request_types"); } } } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index fed778a..73488db 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -54,6 +54,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("section"); + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + b.HasKey("Id") .HasName("pk_request_types"); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index e46fa47..d861696 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -13,7 +13,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() { // Action - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation", null, null, "General"), CancellationToken.None); // Assert created.IsSuccess.ShouldBeTrue(); @@ -43,11 +43,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task UpdateAsync_ChangesFields() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old", null, null, "General"), CancellationToken.None); // Action var updated = await _service.UpdateAsync( - created.Value.Id, new UpdateRequestTypeRequest("New name", "/new", null, "General"), CancellationToken.None); + created.Value.Id, new UpdateRequestTypeRequest("New name", "/new", null, null, "General"), CancellationToken.None); // Assert updated.IsSuccess.ShouldBeTrue(); @@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.UpdateAsync( - 999999, new UpdateRequestTypeRequest("X", "/x", null, "General"), CancellationToken.None); + 999999, new UpdateRequestTypeRequest("X", "/x", null, null, "General"), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -70,11 +70,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task PatchAsync_UpdatesOnlyProvidedFields() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original", null, null, "General"), CancellationToken.None); // Action var patched = await _service.PatchAsync( - created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListName: null, Section: null), CancellationToken.None); + created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None); // Assert patched.IsSuccess.ShouldBeTrue(); @@ -87,7 +87,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.PatchAsync( - 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListName: null, Section: null), CancellationToken.None); + 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -97,7 +97,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task DeleteAsync_RemovesEntity() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp", null, null, "General"), CancellationToken.None); // Action var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); @@ -109,11 +109,37 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I fetched.IsFailed.ShouldBeTrue(); } + [Fact(DisplayName = "Создание типа заявки с list_name без site_url возвращает неуспешный результат")] + public async Task CreateAsync_ListNameWithoutSiteUrl_ReturnsFailedResult() + { + // Action + var result = await _service.CreateAsync( + new CreateRequestTypeRequest("Missing site url", "/missing-site-url", "Requests", null, "General"), CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Создание типа заявки с list_name и site_url успешно")] + public async Task CreateAsync_ListNameWithSiteUrl_ReturnsCreatedEntity() + { + // Action + var created = await _service.CreateAsync( + new CreateRequestTypeRequest( + "With list reference", "/with-list-reference", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"), + CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + created.Value.ListName.ShouldBe("Requests"); + created.Value.SiteUrl.ShouldBe("https://tnsukz.sharepoint.com/sites/hrm"); + } + [Fact(DisplayName = "Список типов заявок включает ранее созданный тип")] public async Task ListAsync_IncludesCreatedEntity() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed", null, null, "General"), CancellationToken.None); // Action var list = await _service.ListAsync(CancellationToken.None); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 471ce96..101fd69 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -38,7 +38,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture CreateRequestTypeAsync(string name) => - (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, "General"), CancellationToken.None)).Value.Id; + (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id; [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() @@ -196,7 +196,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates", null, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); @@ -272,7 +272,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Position request", "/position-request", null, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Position request", "/position-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); @@ -311,7 +311,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Chief request", "/chief-request", null, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Chief request", "/chief-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); @@ -346,7 +346,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request", null, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 8efbe4a..7586761 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -26,8 +26,9 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture CreateRouteAsync(string requestTypeName, bool byProject, string? listName = null) { + var siteUrl = listName is null ? null : "https://example.sharepoint.com/sites/test"; var requestTypeId = (await _requestTypeService.CreateAsync( - new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}", listName, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}", listName, siteUrl, "General"), CancellationToken.None)).Value.Id; var route = await _routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a64856d..0a4f4b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,10 +11,12 @@ "@fluentui/react": "^8.106.4", "@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-core-library": "1.22.1", + "@microsoft/sp-http": "1.22.1", "@microsoft/sp-lodash-subset": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1", + "@pnp/sp": "^4.20.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" @@ -5051,6 +5053,75 @@ "dev": true, "license": "0BSD" }, + "node_modules/@pnp/core": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@pnp/core/-/core-4.20.0.tgz", + "integrity": "sha512-Pq421YYRxAEF3PEKSnhIpz1M5yqmehdmidn+0ImYKixI9V2UQgoWUohcb7bA2DiHXcM8HJl2nF/62PUmBUVAaQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@pnp/queryable": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@pnp/queryable/-/queryable-4.20.0.tgz", + "integrity": "sha512-JfnNrwPUYMP76gBx6rlVM4BGfsA+bU8bVYTY+RyHRPT4S0SIX2w6witvWHKTC0Ara9/JGGnbCI3LyQJ4457nGg==", + "license": "MIT", + "dependencies": { + "@pnp/core": "4.20.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/queryable/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@pnp/sp": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@pnp/sp/-/sp-4.20.0.tgz", + "integrity": "sha512-vz+45igqipK9ZJj/jzRMpK6OqI2Szi1Z48J9QtLCBeZVxT0IwKMT/HMkZca03cQL7IDRPSjq27DYcXQRYeRw3w==", + "license": "MIT", + "dependencies": { + "@pnp/core": "4.20.0", + "@pnp/queryable": "4.20.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/sp/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@rushstack/debug-certificate-manager": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7e04f47..dfde9d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@fluentui/react": "^8.106.4", + "@pnp/sp": "^4.20.0", "@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-http": "1.22.1", diff --git a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx index 7064d0c..63902c0 100644 --- a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx @@ -5,6 +5,7 @@ import { AdminApp } from './app/AdminApp'; import { ApprovalRouteApi } from '../services/api'; import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient'; import { PortalDictionaryClient } from '../services/portalDictionary'; +import { SharePointListsClient } from '../services/sharePointLists'; import { texts } from '../texts'; const FONTS_LINK_ID = 'tnsu-approval-admin-fonts'; @@ -30,6 +31,7 @@ const ApprovalAdmin: React.FC = props => { const [api, setApi] = React.useState(); const [dictionary, setDictionary] = React.useState(); const [error, setError] = React.useState(); + const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]); React.useEffect(() => { ensureFontsLoaded(); @@ -80,6 +82,7 @@ const ApprovalAdmin: React.FC = props => { )} diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 514922f..a2b645f 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -4,7 +4,8 @@ import { TopNav } from './TopNav'; import { ScreenKey } from './types'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; -import { TemplateRoute } from '../../models'; +import { SharePointListsClient } from '../../services/sharePointLists'; +import { RequestType, TemplateRoute } from '../../models'; import { DashboardScreen } from '../screens/DashboardScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RoutesScreen } from '../screens/RoutesScreen'; @@ -15,13 +16,14 @@ export interface IAdminAppProps { api: ApprovalRouteApi; /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; + sharePoint: SharePointListsClient; currentUserEmail?: string; } -export const AdminApp: React.FC = ({ api, dictionary, currentUserEmail }) => { +export const AdminApp: React.FC = ({ api, dictionary, sharePoint, currentUserEmail }) => { const [screen, setScreen] = React.useState('dashboard'); const [selectedRoute, setSelectedRoute] = React.useState(); - const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState(''); + const [selectedRequestType, setSelectedRequestType] = React.useState(); const navigate = (key: ScreenKey): void => { setScreen(key); @@ -29,9 +31,10 @@ export const AdminApp: React.FC = ({ api, dictionary, currentUse const openSteps = (route: TemplateRoute): void => { setSelectedRoute(route); + setSelectedRequestType(undefined); api.requestTypes.get(route.requestTypeId) - .then(rt => setSelectedRequestTypeName(rt.name)) - .catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`)); + .then(setSelectedRequestType) + .catch(() => setSelectedRequestType(undefined)); setScreen('routeSteps'); }; @@ -40,14 +43,15 @@ export const AdminApp: React.FC = ({ api, dictionary, currentUse
{screen === 'dashboard' && } - {screen === 'requestTypes' && } + {screen === 'requestTypes' && } {screen === 'routes' && } {screen === 'routeSteps' && selectedRoute && ( setScreen('routes')} /> )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx index 2fcb723..a6c8cd4 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import { ApprovalRouteApi } from '../../services/api'; +import { SharePointListsClient } from '../../services/sharePointLists'; import { RequestType } from '../../models'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; +import { useSharePointLists } from '../../hooks/useSharePointLists'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { @@ -18,27 +20,33 @@ import { IconButton, PageHeader, PrimaryCell, + Select, TextInput } from '../ui'; export interface IRequestTypesScreenProps { api: ApprovalRouteApi; + sharePoint: SharePointListsClient; } interface IFormState { name: string; pageUrl: string; + section: string; + siteUrl: string; + listName: string; } -const EMPTY_FORM: IFormState = { name: '', pageUrl: '' }; +const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' }; -export const RequestTypesScreen: React.FC = ({ api }) => { +export const RequestTypesScreen: React.FC = ({ api, sharePoint }) => { const list = useRequestTypes(api); const [editing, setEditing] = React.useState(); const [showForm, setShowForm] = React.useState(false); const [form, setForm] = React.useState(EMPTY_FORM); const submit = useAsyncSubmit(); + const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); const deleteTarget = useDeleteTarget(item => api.requestTypes.remove(item.id), list.reload); @@ -50,16 +58,35 @@ export const RequestTypesScreen: React.FC = ({ api }) const openEdit = (item: RequestType): void => { setEditing(item); - setForm({ name: item.name, pageUrl: item.pageUrl }); + setForm({ + name: item.name, + pageUrl: item.pageUrl, + section: item.section, + siteUrl: item.siteUrl ?? '', + listName: item.listName ?? '' + }); setShowForm(true); }; const submitForm = (): void => { - if (!form.name.trim() || !form.pageUrl.trim()) { + if (!form.name.trim() || !form.pageUrl.trim() || !form.section.trim()) { submit.fail(texts.requestTypes.validationRequired); return; } - const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() }; + const siteUrl = form.siteUrl.trim(); + const listName = form.listName.trim(); + if (Boolean(siteUrl) !== Boolean(listName)) { + submit.fail(texts.requestTypes.validationListReference); + return; + } + + const input = { + name: form.name.trim(), + pageUrl: form.pageUrl.trim(), + section: form.section.trim(), + siteUrl: siteUrl || null, + listName: listName || null + }; const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input); submit.run(() => @@ -70,6 +97,8 @@ export const RequestTypesScreen: React.FC = ({ api }) ); }; + const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title })); + return (
@@ -87,6 +116,7 @@ export const RequestTypesScreen: React.FC = ({ api }) columns={[ { key: 'name', header: texts.requestTypes.columnName, render: r => {r.name} }, { key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl }, + { key: 'section', header: texts.requestTypes.columnSection, render: r => r.section }, { key: 'actions', header: '', @@ -129,6 +159,41 @@ export const RequestTypesScreen: React.FC = ({ api }) placeholder={texts.requestTypes.pageUrlPlaceholder} /> + + setForm({ ...form, section: e.target.value })} + placeholder={texts.requestTypes.sectionPlaceholder} + /> + + + setForm({ ...form, siteUrl: e.target.value, listName: '' })} + placeholder={texts.requestTypes.siteUrlPlaceholder} + /> + + + setForm({ ...form, fieldName: e.target.value })} + /> + + + setForm({ ...form, operation: e.target.value as ComparisonOperator })} /> setForm({ ...form, value: e.target.value })} placeholder={texts.routeSteps.conditionValuePlaceholder} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx index ba9a4d4..1c2a758 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx @@ -5,7 +5,7 @@ import { PortalDictionaryClient } from '../../services/portalDictionary'; import { SharePointListsClient } from '../../services/sharePointLists'; import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; -import { describeStepExecutor } from '../../utils/stepExecutor'; +import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor'; import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; @@ -121,6 +121,11 @@ export const RouteStepsScreen: React.FC = ({ api, route, align: 'center', render: r => (r.allowReassignToOtherApprovers ? {texts.routeSteps.reassignAllowed} : {texts.common.no}) }, + { + key: 'condition', + header: texts.routeSteps.columnCondition, + render: r => describeStepCondition(r) ?? texts.common.empty + }, { key: 'actions', header: '', diff --git a/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts b/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts index afa49ee..e3f7c05 100644 --- a/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts +++ b/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts @@ -2,6 +2,7 @@ import { spfi, SPFI, SPFx } from '@pnp/sp'; import '@pnp/sp/webs'; import '@pnp/sp/lists'; import '@pnp/sp/fields'; +import '@pnp/sp/search'; import { WebPartContext } from '@microsoft/sp-webpart-base'; // Списки, из которых типы заявок берут list_name/field_name, живут на произвольном @@ -9,6 +10,10 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; // размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url // (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт. // См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт». +// +// Поиск сайтов (searchSites), наоборот, идёт через поисковый индекс SharePoint — +// он общий на весь тенант независимо от того, с какого сайта пришёл запрос, +// поэтому там клиент строится под текущий сайт веб-части (SPFx(context) без url). export interface SharePointList { id: string; @@ -18,6 +23,26 @@ export interface SharePointList { export interface SharePointListField { internalName: string; title: string; + /** SharePoint TypeAsString (Text, Number, Currency, DateTime, Choice, ...). */ + typeAsString: string; +} + +export interface SharePointSiteSuggestion { + title: string; + url: string; +} + +export interface SharePointSiteSearchResult { + items: SharePointSiteSuggestion[]; + hasMore: boolean; +} + +// SharePoint TypeAsString для колонок с числовыми значениями — только для них +// имеют смысл операторы «больше»/«меньше» (см. RouteStepFormModal). +const NUMERIC_FIELD_TYPES = new Set(['Number', 'Currency']); + +export function isNumericFieldType(typeAsString: string): boolean { + return NUMERIC_FIELD_TYPES.has(typeAsString); } interface IListItem { @@ -28,6 +53,12 @@ interface IListItem { interface IFieldItem { InternalName: string; Title: string; + TypeAsString: string; +} + +interface ISearchResultRow { + Title?: string; + Path?: string; } function clientFor(context: WebPartContext, siteUrl: string): SPFI { @@ -48,14 +79,42 @@ export class SharePointListsClient { return items.map(x => ({ id: x.Id, title: x.Title })); } - /** Заголовки столбцов (внутреннее + отображаемое имя) списка — исключая служебные поля. */ + /** Заголовки столбцов (внутреннее + отображаемое имя + тип) списка — исключая служебные поля. */ public async listFields(siteUrl: string, listTitle: string): Promise { const sp = clientFor(this.context, siteUrl); const items = await sp.web.lists.getByTitle(listTitle).fields - .select('InternalName', 'Title') + .select('InternalName', 'Title', 'TypeAsString') .filter('Hidden eq false and ReadOnlyField eq false') .orderBy('Title')() as IFieldItem[]; - return items.map(x => ({ internalName: x.InternalName, title: x.Title })); + return items.map(x => ({ internalName: x.InternalName, title: x.Title, typeAsString: x.TypeAsString })); + } + + /** + * Поиск сайтов (site collections) по всему тенанту через поисковый индекс + * SharePoint (contentclass:STS_Site) — не требует заранее знать site_url. + * Результаты обрезаны правами доступа текущего пользователя, как и любой + * SharePoint Search запрос. + */ + public async searchSites(query: string, page: number, pageSize = 20): Promise { + const sp = spfi().using(SPFx(this.context)); + const escaped = query.trim().replace(/["*()]/g, ''); + const queryText = escaped ? `contentclass:STS_Site ${escaped}*` : 'contentclass:STS_Site'; + + const result = await sp.search({ + Querytext: queryText, + SelectProperties: ['Title', 'Path'], + RowLimit: pageSize, + StartRow: (page - 1) * pageSize, + TrimDuplicates: true + }); + + const rows = (result.PrimarySearchResults ?? []) as ISearchResultRow[]; + const items = rows + .filter(r => Boolean(r.Path)) + .map(r => ({ title: r.Title || r.Path as string, url: r.Path as string })); + + const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length; + return { items, hasMore: page * pageSize < totalRows }; } } diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index d90a1ad..ed32219 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -45,6 +45,13 @@ export const comparisonOperatorLabels: Record = { [ComparisonOperator.Equal]: 'Равно (=)' }; +export const comparisonOperatorSymbols: Record = { + [ComparisonOperator.None]: '', + [ComparisonOperator.GreaterThan]: '>', + [ComparisonOperator.LessThan]: '<', + [ComparisonOperator.Equal]: '=' +}; + export const texts = { common: { home: 'Главная', @@ -104,6 +111,8 @@ export const texts = { columnName: 'Название', columnPageUrl: 'Url страницы', columnSection: 'Раздел', + columnListName: 'Список SharePoint', + columnSiteUrl: 'Сайт SharePoint', createTitle: 'Новый тип заявки', editTitle: 'Редактировать тип заявки', nameLabel: 'Название', @@ -116,6 +125,7 @@ export const texts = { siteUrlLabel: 'Сайт SharePoint', siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.', siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm', + siteUrlSearchPlaceholder: 'Поиск сайта по названию...', listNameLabel: 'Список SharePoint', listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.', listNamePlaceholder: 'Сначала укажите сайт SharePoint', @@ -184,6 +194,7 @@ export const texts = { columnExecutor: 'Исполнитель', columnStatus: 'Статус', columnReassign: 'Переназначение', + columnCondition: 'Условие', reassignAllowed: 'Разрешено', executorRole: (roleLabel: string): string => `Роль: ${roleLabel}`, executorHead: (headLabel: string): string => `Руководитель: ${headLabel}`, @@ -222,6 +233,7 @@ export const texts = { validationExecutor: 'Выберите сотрудника-исполнителя.', validationPosition: 'Укажите должность.', validationCondition: 'Заполните поле списка, оператор и значение условия.', + validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.', deleteTitle: 'Удалить шаг?', deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` }, diff --git a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts index 36b61e2..a94ba2d 100644 --- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts +++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts @@ -1,5 +1,5 @@ -import { Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models'; -import { headTypeLabels, projectRoleLabels, texts } from '../texts'; +import { ComparisonOperator, Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models'; +import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } from '../texts'; import { findEmployeeByEmail } from './employee'; /** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */ @@ -36,3 +36,9 @@ export function describeStepExecutor(step: TemplateRouteStep, employees: Employe } return { primary: texts.common.empty }; } + +/** Колонка «Условие» в таблице шагов — undefined, если у шага условия нет. */ +export function describeStepCondition(step: TemplateRouteStep): string | undefined { + if (!step.fieldName || !step.operation || step.operation === ComparisonOperator.None || !step.value) return undefined; + return `${step.fieldName} ${comparisonOperatorSymbols[step.operation]} ${step.value}`; +} -- 2.34.1 From de60fd0fdb4d30d0633b2d4baa7744d8ff8ccd5e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 31 Jul 2026 14:38:31 +0500 Subject: [PATCH 05/14] =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateRoutes/ResolveTemplateRouteDto.cs | 1 - .../TemplateRoutes/TemplateRouteDto.cs | 6 --- .../TemplateRoutes/TemplateRouteService.cs | 9 ++--- .../Entities/TemplateRoute.cs | 3 -- .../Enums/RouteType.cs | 8 ---- .../Persistence/ApprovalRouteDbContext.cs | 1 - ...SectionAndStepConditionFields.Designer.cs} | 8 +--- ...eListNameSectionAndStepConditionFields.cs} | 12 ++++++ .../ApprovalRouteDbContextModelSnapshot.cs | 6 --- .../TemplateRouteServiceTests.cs | 38 +++++++++---------- .../TemplateRouteStepServiceTests.cs | 2 +- .../components/screens/ResolveScreen.tsx | 4 +- .../components/screens/RouteFormModal.tsx | 20 +--------- .../components/screens/RouteStepsScreen.tsx | 4 +- .../components/screens/RoutesScreen.tsx | 9 +---- .../webparts/approvalAdmin/models/enums.ts | 6 --- .../webparts/approvalAdmin/models/resolve.ts | 3 +- .../approvalAdmin/models/templateRoutes.ts | 6 --- frontend/src/webparts/approvalAdmin/texts.ts | 17 ++------- 19 files changed, 48 insertions(+), 115 deletions(-) delete mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteType.cs rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs => 20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs} (96%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs => 20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.cs} (87%) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index bf5b484..2d13744 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -12,7 +12,6 @@ public sealed record ResolvedRouteStepDto( public sealed record ResolveTemplateRouteResultDto( int RouteId, - RouteType RouteType, bool RequiresControl, IReadOnlyList Steps); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs index 6992701..82aee23 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -1,10 +1,7 @@ -using Tnsu.ApprovalRoute.Domain.Enums; - namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; public sealed record TemplateRouteDto( int Id, - RouteType RouteType, int RequestTypeId, Guid? DepartmentId, Guid CompanyId, @@ -12,7 +9,6 @@ public sealed record TemplateRouteDto( bool RequiresControl); public sealed record CreateTemplateRouteRequest( - RouteType RouteType, int RequestTypeId, Guid? DepartmentId, Guid CompanyId, @@ -20,14 +16,12 @@ public sealed record CreateTemplateRouteRequest( bool RequiresControl); public sealed record UpdateTemplateRouteRequest( - RouteType RouteType, Guid? DepartmentId, Guid CompanyId, bool ByProject, bool RequiresControl); public sealed record PatchTemplateRouteRequest( - RouteType? RouteType, Guid? DepartmentId, Guid? CompanyId, bool? ByProject, diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index a513f06..94e2de1 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -24,7 +24,7 @@ public sealed class TemplateRouteService( var items = await query .Select(x => new TemplateRouteDto( - x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) + x.Id, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -52,7 +52,6 @@ public sealed class TemplateRouteService( var entity = new TemplateRoute { - RouteType = r.RouteType, RequestTypeId = r.RequestTypeId, DepartmentId = r.DepartmentId, CompanyId = r.CompanyId, @@ -76,7 +75,6 @@ public sealed class TemplateRouteService( return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); - entity.RouteType = r.RouteType; entity.DepartmentId = r.DepartmentId; entity.CompanyId = r.CompanyId; entity.ByProject = r.ByProject; @@ -101,7 +99,6 @@ public sealed class TemplateRouteService( return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); - entity.RouteType = r.RouteType ?? entity.RouteType; entity.DepartmentId = departmentId; entity.CompanyId = companyId; entity.ByProject = byProject; @@ -179,7 +176,7 @@ public sealed class TemplateRouteService( var collapsedSteps = CollapseByExecutorEmail(steps); - return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps)); + return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); } private Task HasDuplicateAsync( @@ -260,5 +257,5 @@ public sealed class TemplateRouteService( } private static TemplateRouteDto ToDto(TemplateRoute e) => new( - e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); + e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index 9319917..c5ac975 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -1,11 +1,8 @@ -using Tnsu.ApprovalRoute.Domain.Enums; - namespace Tnsu.ApprovalRoute.Domain.Entities; public class TemplateRoute { public int Id { get; set; } - public RouteType RouteType { get; set; } = RouteType.Sequential; public int RequestTypeId { get; set; } public Guid? DepartmentId { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteType.cs deleted file mode 100644 index 8004453..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Tnsu.ApprovalRoute.Domain.Enums; - -public enum RouteType -{ - None, - Parallel, - Sequential -} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 4f4bb5d..e9ba224 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -24,7 +24,6 @@ public class ApprovalRouteDbContext(DbContextOptions opt modelBuilder.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.RouteType).HasConversion().HasMaxLength(32).IsRequired(); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique(); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs similarity index 96% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs index a49cb0c..011788e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs @@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260731073536_AddRequestTypeListNameSectionAndStepConditionFields")] + [Migration("20260731093159_AddRequestTypeListNameSectionAndStepConditionFields")] partial class AddRequestTypeListNameSectionAndStepConditionFields { /// @@ -97,12 +97,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("requires_control"); - b.Property("RouteType") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("route_type"); - b.HasKey("Id") .HasName("pk_template_routes"); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.cs similarity index 87% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.cs index d6dfe6e..29c2f4f 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.cs @@ -10,6 +10,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.DropColumn( + name: "route_type", + table: "template_routes"); + migrationBuilder.AddColumn( name: "field_name", table: "template_route_steps", @@ -80,6 +84,14 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations migrationBuilder.DropColumn( name: "site_url", table: "request_types"); + + migrationBuilder.AddColumn( + name: "route_type", + table: "template_routes", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); } } } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 73488db..0d54ade 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -94,12 +94,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("requires_control"); - b.Property("RouteType") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("route_type"); - b.HasKey("Id") .HasName("pk_template_routes"); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 101fd69..7f0f80b 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -48,7 +48,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); @@ -198,7 +196,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); @@ -274,7 +272,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); @@ -401,7 +399,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); @@ -429,7 +427,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); @@ -457,7 +455,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture = ({ api, dictionary, {result && ( <> - {texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)} + {texts.resolve.summary(result.routeId, result.requiresControl)} {unresolvedCount > 0 && {texts.resolve.unresolved(unresolvedCount)}} v !== RouteType.None) - .map(v => ({ value: v, label: routeTypeLabels[v] })); - function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState { if (!editing) { return { requestTypeId: initialRequestTypeId, - routeType: RouteType.Sequential, companyId: '', departmentId: '', byProject: false, @@ -45,7 +39,6 @@ function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: s } return { requestTypeId: String(editing.requestTypeId), - routeType: editing.routeType, companyId: editing.companyId, departmentId: editing.departmentId ?? '', byProject: editing.byProject, @@ -88,14 +81,12 @@ export const RouteFormModal: React.FC = ({ const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null; const request = editing ? api.templateRoutes.update(editing.id, { - routeType: form.routeType, companyId: form.companyId.trim(), departmentId, byProject: form.byProject, requiresControl: form.requiresControl }) : api.templateRoutes.create({ - routeType: form.routeType, requestTypeId: Number(form.requestTypeId), companyId: form.companyId.trim(), departmentId, @@ -129,13 +120,6 @@ export const RouteFormModal: React.FC = ({ onChange={e => setForm({ ...form, requestTypeId: e.target.value })} /> - - setStatus(e.target.value as RouteStepStatus)} /> + setQuery(e.target.value)} - /> -
- {loading &&
{texts.select.searching}
} - {!loading && error &&
{error}
} - {!loading && !error && items.length === 0 && ( -
{texts.select.nothingFound}
- )} - {!loading && !error && items.map(item => { - const itemValue = getValue(item); - const sub = getSublabel ? getSublabel(item) : undefined; - return ( - - ); - })} - {!loading && !error && hasMore && ( -
- {loadingMore && 'Загрузка...'} -
- )} -
- {allowClear && value && ( -
- + ); + })} + {!loading && !error && hasMore && ( +
+ {loadingMore && 'Загрузка...'}
)}
- )} + {allowClear && value && ( +
+ +
+ )} +
); } diff --git a/frontend/src/webparts/approvalAdmin/components/ui/DropdownPortal.tsx b/frontend/src/webparts/approvalAdmin/components/ui/DropdownPortal.tsx new file mode 100644 index 0000000..0e2f0c3 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/DropdownPortal.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +export interface IDropdownPortalProps { + /** Элемент, под которым (или над которым) рисуется меню. */ + anchorRef: React.RefObject; + open: boolean; + className?: string; + /** Прокидывается наружу — нужен обработчику клика вне меню (портал живёт вне DOM-дерева root). */ + menuRef?: React.Ref; + children: React.ReactNode; +} + +interface IPosition { + top?: number; + bottom?: number; + left: number; + width: number; +} + +const GAP = 6; +const MIN_SPACE_BELOW = 240; + +/** + * Рендерит выпадающее меню в document.body с position: fixed по координатам + * anchor-элемента, а не как обычный absolute-потомок. Внутри модалок наши формы + * скроллятся через overflow-y: auto на .box (Modal.module.scss) — обычный + * position: absolute-дочерний элемент обрезается этим скроллом, если список не + * помещается. Портал избегает этого, т.к. живёт вне скроллящегося поддерева. + */ +export const DropdownPortal: React.FC = ({ anchorRef, open, className, menuRef, children }) => { + const [position, setPosition] = React.useState(); + + const updatePosition = React.useCallback(() => { + const el = anchorRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const openUp = spaceBelow < MIN_SPACE_BELOW && rect.top > spaceBelow; + + setPosition( + openUp + ? { bottom: window.innerHeight - rect.top + GAP, left: rect.left, width: rect.width } + : { top: rect.bottom + GAP, left: rect.left, width: rect.width } + ); + }, [anchorRef]); + + React.useLayoutEffect(() => { + if (!open) return undefined; + updatePosition(); + + // capture: true — ловим скролл любого предка (в т.ч. .box модалки), не только window. + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [open, updatePosition]); + + if (!open || !position) return null; + + const style: React.CSSProperties = { + position: 'fixed', + left: position.left, + width: position.width, + top: position.top, + bottom: position.bottom + }; + + return ReactDOM.createPortal( +
+ {children} +
, + document.body + ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss index b587b26..9995862 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss @@ -40,10 +40,9 @@ .chevron { color: $gray-400; flex-shrink: 0; } .menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; + // position/top/left/width задаются инлайн из DropdownPortal (position: fixed + // по координатам триггера) — портал рендерится в document.body, чтобы список + // не обрезался overflow-y: auto модалки (Modal.module.scss .box). background: #fff; border-radius: $r-md; box-shadow: $sh-modal; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx index 7f99b0e..055e0e9 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import styles from './SearchableSelect.module.scss'; +import { DropdownPortal } from './DropdownPortal'; import { Icon } from './Icon'; import { texts } from '../../texts'; @@ -40,6 +41,7 @@ export const SearchableSelect: React.FC = ({ const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); const rootRef = React.useRef(null); + const menuRef = React.useRef(null); const searchRef = React.useRef(null); // Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С @@ -68,9 +70,10 @@ export const SearchableSelect: React.FC = ({ if (!open) return; const onDocumentMouseDown = (e: MouseEvent): void => { - if (rootRef.current && !rootRef.current.contains(e.target as Node)) { - setOpen(false); - } + const target = e.target as Node; + if (rootRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setOpen(false); }; const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false); @@ -113,42 +116,40 @@ export const SearchableSelect: React.FC = ({ - {open && ( -
- setQuery(e.target.value)} - /> -
- {filtered.length === 0 && ( -
- {effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound} -
- )} - {filtered.map(opt => ( - - ))} -
- {allowClear && value && ( -
- + + setQuery(e.target.value)} + /> +
+ {filtered.length === 0 && ( +
+ {effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
)} + {filtered.map(opt => ( + + ))}
- )} + {allowClear && value && ( +
+ +
+ )} +
); }; diff --git a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts new file mode 100644 index 0000000..e080796 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; + +/** + * Как React.useState, но значение переживает перезагрузку страницы — хранится + * в localStorage под key. Падение доступа к localStorage (приватный режим, + * квота) не ломает форму — просто не персистит. + */ +export function useLocalStorageState(key: string, initial: T): [T, React.Dispatch>] { + const [value, setValue] = React.useState(() => { + try { + const raw = window.localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : initial; + } catch { + return initial; + } + }); + + React.useEffect(() => { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // приватный режим / переполненная квота — не критично, просто не персистим + } + }, [key, value]); + + return [value, setValue]; +} -- 2.34.1 From 8a0bb146e980da45e7de8a7e697f94345860f920 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 31 Jul 2026 15:09:04 +0500 Subject: [PATCH 07/14] =?UTF-8?q?=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D1=81=D1=82=D0=B8=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/styles/design-tokens.scss | 15 ++++++------ .../components/app/TopNav.module.scss | 14 ----------- .../approvalAdmin/components/app/TopNav.tsx | 1 - .../components/screens/RouteStepsScreen.tsx | 2 +- .../components/ui/PageChrome.module.scss | 23 +++++-------------- frontend/src/webparts/approvalAdmin/texts.ts | 2 -- 6 files changed, 14 insertions(+), 43 deletions(-) diff --git a/frontend/src/styles/design-tokens.scss b/frontend/src/styles/design-tokens.scss index 90c6b76..d7e2edc 100644 --- a/frontend/src/styles/design-tokens.scss +++ b/frontend/src/styles/design-tokens.scss @@ -106,19 +106,18 @@ $sp-7: 28px; $sp-8: 32px; } // ---- Готовые миксины ---- +// Светлая карточка заголовка страницы — сознательно НЕ грд-header (тёмный, +// как TopNav): два одинаковых тёмно-синих блока подряд читаются как один +// тяжёлый слипшийся элемент. Золотая полоса сверху — единственный акцент, +// вместо заливки всей карточки. @mixin page-header { - background: $grad-header; + background: #fff; + border: 1px solid $gray-200; + border-top: 3px solid $gold-500; border-radius: $r-lg; padding: 26px 30px; position: relative; overflow: hidden; - &::before { - content: ''; - position: absolute; - left: 0; right: 0; bottom: 0; - height: 2px; - background: linear-gradient(90deg, $gold-500, transparent 60%); - } } @mixin card-hover-gold { diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss b/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss index 78e5d8c..f283b56 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss @@ -23,20 +23,6 @@ flex-shrink: 0; } -.mark { - width: 30px; - height: 30px; - border-radius: 7px; - background: $grad-gold-v; - display: flex; - align-items: center; - justify-content: center; - color: $navy-950; - font-weight: 800; - font-size: 13px; - font-family: $font-serif; -} - .brandText { color: #fff; font-weight: 600; diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx b/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx index 128a231..506ef05 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx @@ -72,7 +72,6 @@ export const TopNav: React.FC = ({ active, onSelect }) => { return (
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.module.scss index add0064..bc814a0 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.module.scss @@ -1,3 +1,37 @@ +@import '../../../../styles/design-tokens'; + +.conditions { + margin-top: 20px; + padding-top: 18px; + border-top: 1px solid $gray-200; +} + +.conditionsHeader { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 12px; +} + +.conditionsTitle { + font-size: 13px; + font-weight: 600; + color: $gray-700; +} + +.conditionsHint { + font-size: 11.5px; + color: $gray-400; +} + +.conditionRow { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} + .runButtonRow { margin-top: 18px; } diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx index 7973d78..37c8e45 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx @@ -2,11 +2,13 @@ import * as React from 'react'; import styles from './ResolveScreen.module.scss'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; -import { Project, RouteStepStatus } from '../../models'; +import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists'; +import { Project, ResolveFieldValue, RouteStepStatus } from '../../models'; import { routeStepStatusLabels, texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useResolveRoute } from '../../hooks/useResolveRoute'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; +import { useSharePointFields } from '../../hooks/useSharePointFields'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; import { @@ -18,17 +20,20 @@ import { DataTable, Field, FormGrid, + IconButton, PageHeader, Panel, PrimaryCell, SearchableSelect, - Select + Select, + TextInput } from '../ui'; export interface IResolveScreenProps { api: ApprovalRouteApi; /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; + sharePoint: SharePointListsClient; currentUserEmail?: string; } @@ -46,6 +51,7 @@ interface IResolveFormState { projectId: string; projectLabel: string; status: RouteStepStatus; + fieldValues: ResolveFieldValue[]; } const EMPTY_RESOLVE_FORM: IResolveFormState = { @@ -53,13 +59,19 @@ const EMPTY_RESOLVE_FORM: IResolveFormState = { userEmail: '', projectId: '', projectLabel: '', - status: RouteStepStatus.Approval + status: RouteStepStatus.Approval, + fieldValues: [] }; -export const ResolveScreen: React.FC = ({ api, dictionary, currentUserEmail }) => { +export const ResolveScreen: React.FC = ({ api, dictionary, sharePoint, currentUserEmail }) => { const requestTypes = useRequestTypes(api); const [form, setForm] = useLocalStorageState(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM); - const { requestTypeId, userEmail, projectId, projectLabel, status } = form; + const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form; + + const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId)); + const hasListName = Boolean(requestType?.listName); + const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName); + const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); const resolved = useResolveRoute(api); const employees = usePortalEmployees(dictionary); @@ -74,16 +86,35 @@ export const ResolveScreen: React.FC = ({ api, dictionary, [dictionary] ); + const updateFieldValue = (index: number, patch: Partial): void => { + setForm({ ...form, fieldValues: fieldValues.map((fv, i) => (i === index ? { ...fv, ...patch } : fv)) }); + }; + + const addFieldValue = (): void => { + setForm({ ...form, fieldValues: [...fieldValues, { fieldName: '', value: '' }] }); + }; + + const removeFieldValue = (index: number): void => { + setForm({ ...form, fieldValues: fieldValues.filter((_, i) => i !== index) }); + }; + const run = (): void => { if (!requestTypeId) { resolved.fail(texts.resolve.validationRequestType); return; } + const conditions = fieldValues.filter(fv => fv.fieldName.trim() || fv.value.trim()); + if (conditions.some(fv => !fv.fieldName.trim() || !fv.value.trim())) { + resolved.fail(texts.resolve.validationConditionField); + return; + } + resolved.resolve({ requestTypeId: Number(requestTypeId), userEmail: userEmail.trim() || undefined, projectId: projectId.trim() || undefined, - status + status, + fieldValues: conditions.length > 0 ? conditions : undefined }); }; @@ -102,7 +133,7 @@ export const ResolveScreen: React.FC = ({ api, dictionary, options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))} placeholder={texts.resolve.requestTypePlaceholder} value={requestTypeId} - onChange={e => setForm({ ...form, requestTypeId: e.target.value })} + onChange={e => setForm({ ...form, requestTypeId: e.target.value, fieldValues: [] })} /> = ({ api, dictionary, /> + +
+
+ {texts.resolve.conditionsLabel} + {requestTypeId && !hasListName && ( + {texts.resolve.conditionsDisabledHint} + )} +
+ + {hasListName && ( + <> + {fieldValues.map((fv, index) => { + const field = sharePointFields.items.find(f => f.internalName === fv.fieldName); + const isNumeric = field ? isNumericFieldType(field.typeAsString) : false; + return ( + // eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом +
+