фронт поправлен

This commit is contained in:
gorbenko.pavel
2026-07-31 12:51:48 +05:00
parent fdd3787e43
commit 06b52c5186
25 changed files with 573 additions and 45 deletions
@@ -1,9 +1,9 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypes; 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);
@@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
{ {
var items = await db.RequestTypes.AsNoTracking() var items = await db.RequestTypes.AsNoTracking()
.OrderBy(x => x.Name) .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); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items); return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
@@ -29,11 +29,16 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
{ {
if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
var entity = new RequestType var entity = new RequestType
{ {
Name = request.Name, Name = request.Name,
PageUrl = request.PageUrl, PageUrl = request.PageUrl,
ListName = request.ListName, ListName = request.ListName,
SiteUrl = request.SiteUrl,
Section = request.Section Section = request.Section
}; };
db.RequestTypes.Add(entity); db.RequestTypes.Add(entity);
@@ -48,9 +53,14 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
if (entity is null) if (entity is null)
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id)); return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
entity.Name = request.Name; entity.Name = request.Name;
entity.PageUrl = request.PageUrl; entity.PageUrl = request.PageUrl;
entity.ListName = request.ListName; entity.ListName = request.ListName;
entity.SiteUrl = request.SiteUrl;
entity.Section = request.Section; entity.Section = request.Section;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -63,9 +73,17 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
if (entity is null) if (entity is null)
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id)); return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
var listName = request.ListName ?? entity.ListName;
var siteUrl = request.SiteUrl ?? entity.SiteUrl;
if (!RequestTypeFields.IsListReferenceValid(listName, siteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
entity.Name = request.Name ?? entity.Name; entity.Name = request.Name ?? entity.Name;
entity.PageUrl = request.PageUrl ?? entity.PageUrl; entity.PageUrl = request.PageUrl ?? entity.PageUrl;
entity.ListName = request.ListName ?? entity.ListName; entity.ListName = listName;
entity.SiteUrl = siteUrl;
entity.Section = request.Section ?? entity.Section; entity.Section = request.Section ?? entity.Section;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -84,5 +102,5 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
return Result.Ok(); 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);
} }
@@ -9,6 +9,9 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator<Create
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); 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<Update
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); 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<PatchRe
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым.")); RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым."));
} }
} }
internal static class RequestTypeFields
{
public static bool IsListReferenceValid(string? listName, string? siteUrl) =>
string.IsNullOrWhiteSpace(listName) == string.IsNullOrWhiteSpace(siteUrl);
}
@@ -6,6 +6,7 @@ public class RequestType
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string PageUrl { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty;
public string? ListName { get; set; } public string? ListName { get; set; }
public string? SiteUrl { get; set; }
public string Section { get; set; } = string.Empty; public string Section { get; set; } = string.Empty;
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>(); public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
@@ -17,6 +17,7 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.Name).HasMaxLength(256).IsRequired(); e.Property(x => x.Name).HasMaxLength(256).IsRequired();
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
e.Property(x => x.ListName).HasMaxLength(256); e.Property(x => x.ListName).HasMaxLength(256);
e.Property(x => x.SiteUrl).HasMaxLength(512);
e.Property(x => x.Section).HasMaxLength(256).IsRequired(); e.Property(x => x.Section).HasMaxLength(256).IsRequired();
}); });
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{ {
[DbContext(typeof(ApprovalRouteDbContext))] [DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260731062407_AddRequestTypeListNameSectionAndStepConditionFields")] [Migration("20260731073536_AddRequestTypeListNameSectionAndStepConditionFields")]
partial class AddRequestTypeListNameSectionAndStepConditionFields partial class AddRequestTypeListNameSectionAndStepConditionFields
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -57,6 +57,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("section"); .HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_request_types"); .HasName("pk_request_types");
@@ -45,6 +45,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
maxLength: 256, maxLength: 256,
nullable: false, nullable: false,
defaultValue: ""); defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "site_url",
table: "request_types",
type: "character varying(512)",
maxLength: 512,
nullable: true);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -69,6 +76,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
name: "section", name: "section",
table: "request_types"); table: "request_types");
migrationBuilder.DropColumn(
name: "site_url",
table: "request_types");
} }
} }
} }
@@ -54,6 +54,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("section"); .HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_request_types"); .HasName("pk_request_types");
@@ -13,7 +13,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity()
{ {
// Action // 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 // Assert
created.IsSuccess.ShouldBeTrue(); created.IsSuccess.ShouldBeTrue();
@@ -43,11 +43,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task UpdateAsync_ChangesFields() public async Task UpdateAsync_ChangesFields()
{ {
// Arrange // 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 // Action
var updated = await _service.UpdateAsync( 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 // Assert
updated.IsSuccess.ShouldBeTrue(); updated.IsSuccess.ShouldBeTrue();
@@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
{ {
// Action // Action
var result = await _service.UpdateAsync( 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 // Assert
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
@@ -70,11 +70,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task PatchAsync_UpdatesOnlyProvidedFields() public async Task PatchAsync_UpdatesOnlyProvidedFields()
{ {
// Arrange // 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 // Action
var patched = await _service.PatchAsync( 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 // Assert
patched.IsSuccess.ShouldBeTrue(); patched.IsSuccess.ShouldBeTrue();
@@ -87,7 +87,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
{ {
// Action // Action
var result = await _service.PatchAsync( 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 // Assert
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
@@ -97,7 +97,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task DeleteAsync_RemovesEntity() public async Task DeleteAsync_RemovesEntity()
{ {
// Arrange // 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 // Action
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
@@ -109,11 +109,37 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
fetched.IsFailed.ShouldBeTrue(); 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 = "Список типов заявок включает ранее созданный тип")] [Fact(DisplayName = "Список типов заявок включает ранее созданный тип")]
public async Task ListAsync_IncludesCreatedEntity() public async Task ListAsync_IncludesCreatedEntity()
{ {
// Arrange // 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 // Action
var list = await _service.ListAsync(CancellationToken.None); var list = await _service.ListAsync(CancellationToken.None);
@@ -38,7 +38,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
} }
private async Task<int> CreateRequestTypeAsync(string name) => private async Task<int> 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 возвращает сохранённые данные")] [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
@@ -196,7 +196,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( 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( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
@@ -272,7 +272,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( 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( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
@@ -311,7 +311,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( 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( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
@@ -346,7 +346,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( 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( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
@@ -26,8 +26,9 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
private async Task<int> CreateRouteAsync(string requestTypeName, bool byProject, string? listName = null) private async Task<int> 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( 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( var route = await _routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false),
+71
View File
@@ -11,10 +11,12 @@
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-component-base": "1.22.1",
"@microsoft/sp-core-library": "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-lodash-subset": "1.22.1",
"@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -5051,6 +5053,75 @@
"dev": true, "dev": true,
"license": "0BSD" "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": { "node_modules/@rushstack/debug-certificate-manager": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz", "resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz",
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@pnp/sp": "^4.20.0",
"@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-component-base": "1.22.1",
"@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-core-library": "1.22.1",
"@microsoft/sp-http": "1.22.1", "@microsoft/sp-http": "1.22.1",
@@ -5,6 +5,7 @@ import { AdminApp } from './app/AdminApp';
import { ApprovalRouteApi } from '../services/api'; import { ApprovalRouteApi } from '../services/api';
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient'; import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary'; import { PortalDictionaryClient } from '../services/portalDictionary';
import { SharePointListsClient } from '../services/sharePointLists';
import { texts } from '../texts'; import { texts } from '../texts';
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts'; const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
@@ -30,6 +31,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>(); const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>(); const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
const [error, setError] = React.useState<string | undefined>(); const [error, setError] = React.useState<string | undefined>();
const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]);
React.useEffect(() => { React.useEffect(() => {
ensureFontsLoaded(); ensureFontsLoaded();
@@ -80,6 +82,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
<AdminApp <AdminApp
api={api} api={api}
dictionary={dictionary} dictionary={dictionary}
sharePoint={sharePoint}
currentUserEmail={props.context.pageContext.user.email} currentUserEmail={props.context.pageContext.user.email}
/> />
)} )}
@@ -4,7 +4,8 @@ import { TopNav } from './TopNav';
import { ScreenKey } from './types'; import { ScreenKey } from './types';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; 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 { DashboardScreen } from '../screens/DashboardScreen';
import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen';
import { RoutesScreen } from '../screens/RoutesScreen'; import { RoutesScreen } from '../screens/RoutesScreen';
@@ -15,13 +16,14 @@ export interface IAdminAppProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient; dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
currentUserEmail?: string; currentUserEmail?: string;
} }
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => { export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
const [screen, setScreen] = React.useState<ScreenKey>('dashboard'); const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>(); const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState(''); const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
const navigate = (key: ScreenKey): void => { const navigate = (key: ScreenKey): void => {
setScreen(key); setScreen(key);
@@ -29,9 +31,10 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
const openSteps = (route: TemplateRoute): void => { const openSteps = (route: TemplateRoute): void => {
setSelectedRoute(route); setSelectedRoute(route);
setSelectedRequestType(undefined);
api.requestTypes.get(route.requestTypeId) api.requestTypes.get(route.requestTypeId)
.then(rt => setSelectedRequestTypeName(rt.name)) .then(setSelectedRequestType)
.catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`)); .catch(() => setSelectedRequestType(undefined));
setScreen('routeSteps'); setScreen('routeSteps');
}; };
@@ -40,14 +43,15 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
<TopNav active={screen} onSelect={navigate} /> <TopNav active={screen} onSelect={navigate} />
<div className={styles.content}> <div className={styles.content}>
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />} {screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
{screen === 'requestTypes' && <RequestTypesScreen api={api} />} {screen === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />} {screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
{screen === 'routeSteps' && selectedRoute && ( {screen === 'routeSteps' && selectedRoute && (
<RouteStepsScreen <RouteStepsScreen
api={api} api={api}
route={selectedRoute} route={selectedRoute}
requestTypeName={selectedRequestTypeName} requestType={selectedRequestType}
dictionary={dictionary} dictionary={dictionary}
sharePoint={sharePoint}
onBack={() => setScreen('routes')} onBack={() => setScreen('routes')}
/> />
)} )}
@@ -1,8 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType } from '../../models'; import { RequestType } from '../../models';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useSharePointLists } from '../../hooks/useSharePointLists';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { import {
@@ -18,27 +20,33 @@ import {
IconButton, IconButton,
PageHeader, PageHeader,
PrimaryCell, PrimaryCell,
Select,
TextInput TextInput
} from '../ui'; } from '../ui';
export interface IRequestTypesScreenProps { export interface IRequestTypesScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
sharePoint: SharePointListsClient;
} }
interface IFormState { interface IFormState {
name: string; name: string;
pageUrl: 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<IRequestTypesScreenProps> = ({ api }) => { export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
const list = useRequestTypes(api); const list = useRequestTypes(api);
const [editing, setEditing] = React.useState<RequestType | undefined>(); const [editing, setEditing] = React.useState<RequestType | undefined>();
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM); const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload); const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
@@ -50,16 +58,35 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
const openEdit = (item: RequestType): void => { const openEdit = (item: RequestType): void => {
setEditing(item); 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); setShowForm(true);
}; };
const submitForm = (): void => { 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); submit.fail(texts.requestTypes.validationRequired);
return; 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); const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
submit.run(() => submit.run(() =>
@@ -70,6 +97,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
); );
}; };
const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title }));
return ( return (
<div> <div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} /> <Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
@@ -87,6 +116,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
columns={[ columns={[
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> }, { key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl }, { key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
{ {
key: 'actions', key: 'actions',
header: '', header: '',
@@ -129,6 +159,41 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
placeholder={texts.requestTypes.pageUrlPlaceholder} placeholder={texts.requestTypes.pageUrlPlaceholder}
/> />
</Field> </Field>
<Field label={texts.requestTypes.sectionLabel} required full>
<TextInput
value={form.section}
onChange={e => setForm({ ...form, section: e.target.value })}
placeholder={texts.requestTypes.sectionPlaceholder}
/>
</Field>
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
<TextInput
value={form.siteUrl}
onChange={e => setForm({ ...form, siteUrl: e.target.value, listName: '' })}
placeholder={texts.requestTypes.siteUrlPlaceholder}
/>
</Field>
<Field
label={texts.requestTypes.listNameLabel}
full
hint={sharePointLists.error ? texts.requestTypes.listNameError(sharePointLists.error) : texts.requestTypes.listNameHint}
>
<Select
options={listNameOptions}
value={form.listName}
disabled={!form.siteUrl.trim() || sharePointLists.isLoading}
placeholder={
!form.siteUrl.trim()
? texts.requestTypes.listNamePlaceholder
: sharePointLists.isLoading
? texts.requestTypes.listNameLoading
: listNameOptions.length === 0
? texts.requestTypes.listNameEmpty
: texts.select.placeholder
}
onChange={e => setForm({ ...form, listName: e.target.value })}
/>
</Field>
</FormGrid> </FormGrid>
</FormModal> </FormModal>
)} )}
@@ -1,23 +1,30 @@
import * as React from 'react'; import * as React from 'react';
import { import {
ComparisonOperator,
Employee, Employee,
HeadType, HeadType,
ProjectRole, ProjectRole,
RequestType,
RouteStepStatus, RouteStepStatus,
StepExecutorKind, StepExecutorKind,
TemplateRoute, TemplateRoute,
TemplateRouteStep TemplateRouteStep
} from '../../models'; } from '../../models';
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor'; import { detectExecutorKind } from '../../utils/stepExecutor';
import { toEmployeeOption } from '../../utils/employee'; import { toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui'; import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
export interface IRouteStepFormModalProps { export interface IRouteStepFormModalProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
route: TemplateRoute; route: TemplateRoute;
/** Undefined, пока грузится тип заявки маршрута. */
requestType?: RequestType;
sharePoint: SharePointListsClient;
/** Undefined — создание нового шага, иначе редактирование существующего. */ /** Undefined — создание нового шага, иначе редактирование существующего. */
editing?: TemplateRouteStep; editing?: TemplateRouteStep;
nextOrderNo: number; nextOrderNo: number;
@@ -36,6 +43,10 @@ interface IFormState {
projectRole: ProjectRole; projectRole: ProjectRole;
headType: HeadType; headType: HeadType;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
hasCondition: boolean;
fieldName: string;
operation: ComparisonOperator;
value: string;
} }
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
@@ -49,6 +60,10 @@ const HEAD_TYPE_OPTIONS = Object.values(HeadType)
.filter(v => v !== HeadType.None) .filter(v => v !== HeadType.None)
.map(v => ({ value: v, label: headTypeLabels[v] })); .map(v => ({ value: v, label: headTypeLabels[v] }));
const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState { function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) { if (!editing) {
return { return {
@@ -59,7 +74,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
position: '', position: '',
projectRole: ProjectRole.ProjectManager, projectRole: ProjectRole.ProjectManager,
headType: HeadType.Chief, headType: HeadType.Chief,
allowReassignToOtherApprovers: false allowReassignToOtherApprovers: false,
hasCondition: false,
fieldName: '',
operation: ComparisonOperator.GreaterThan,
value: ''
}; };
} }
return { return {
@@ -70,7 +89,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
position: editing.position ?? '', position: editing.position ?? '',
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager, projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief, headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
hasCondition: Boolean(editing.fieldName),
fieldName: editing.fieldName ?? '',
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
value: editing.value ?? ''
}; };
} }
@@ -78,6 +101,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
api, api,
route, route,
requestType,
sharePoint,
editing, editing,
nextOrderNo, nextOrderNo,
employees, employees,
@@ -87,6 +112,16 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
}) => { }) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo)); const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const hasListName = Boolean(requestType?.listName);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
const fieldNameOptions = React.useMemo(() => {
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
options.unshift({ value: form.fieldName, label: form.fieldName });
}
return options;
}, [sharePointFields.items, form.fieldName]);
const submitForm = (): void => { const submitForm = (): void => {
const orderNo = Number(form.orderNo); const orderNo = Number(form.orderNo);
@@ -102,6 +137,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationPosition); submit.fail(texts.routeSteps.validationPosition);
return; return;
} }
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) {
submit.fail(texts.routeSteps.validationCondition);
return;
}
const payload = { const payload = {
orderNo, orderNo,
@@ -110,7 +149,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null, executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
position: form.kind === 'position' ? form.position.trim() : null, position: form.kind === 'position' ? form.position.trim() : null,
projectRole: form.kind === 'projectRole' ? form.projectRole : null, projectRole: form.kind === 'projectRole' ? form.projectRole : null,
headType: form.kind === 'headType' ? form.headType : null headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null,
operation: form.hasCondition ? form.operation : null,
value: form.hasCondition ? form.value.trim() : null
}; };
const request = editing const request = editing
@@ -217,6 +259,54 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })} onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
/> />
</Field> </Field>
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListName ? texts.routeSteps.conditionDisabledHint : undefined}>
<Checkbox
label={texts.routeSteps.conditionCheckbox}
checked={form.hasCondition}
disabled={!hasListName}
onChange={v => setForm({ ...form, hasCondition: v })}
/>
</Field>
{form.hasCondition && hasListName && (
<>
<Field
label={texts.routeSteps.conditionFieldNameLabel}
required
full
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined}
>
<Select
options={fieldNameOptions}
value={form.fieldName}
disabled={sharePointFields.isLoading}
placeholder={
sharePointFields.isLoading
? texts.routeSteps.conditionFieldNameLoading
: fieldNameOptions.length === 0
? texts.routeSteps.conditionFieldNameEmpty
: texts.routeSteps.conditionFieldNamePlaceholder
}
onChange={e => setForm({ ...form, fieldName: e.target.value })}
/>
</Field>
<Field label={texts.routeSteps.conditionOperationLabel} required>
<Select
options={OPERATION_OPTIONS}
value={form.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
/>
</Field>
<Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
</Field>
</>
)}
</FormGrid> </FormGrid>
</FormModal> </FormModal>
); );
@@ -2,7 +2,8 @@ import * as React from 'react';
import { RouteStepFormModal } from './RouteStepFormModal'; import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor'; import { describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useRouteSteps } from '../../hooks/useRouteSteps';
@@ -25,16 +26,19 @@ import {
export interface IRouteStepsScreenProps { export interface IRouteStepsScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
route: TemplateRoute; route: TemplateRoute;
requestTypeName: string; /** Undefined, пока грузится тип заявки (см. AdminApp.openSteps). */
requestType?: RequestType;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient; dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
onBack: () => void; onBack: () => void;
} }
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => { export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
const steps = useRouteSteps(api, route.id); const steps = useRouteSteps(api, route.id);
const employees = usePortalEmployees(dictionary); const employees = usePortalEmployees(dictionary);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>(); const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>(); const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
@@ -140,6 +144,8 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
<RouteStepFormModal <RouteStepFormModal
api={api} api={api}
route={route} route={route}
requestType={requestType}
sharePoint={sharePoint}
editing={editing} editing={editing}
nextOrderNo={nextOrderNo} nextOrderNo={nextOrderNo}
employees={employees.items} employees={employees.items}
@@ -0,0 +1,41 @@
import * as React from 'react';
import { SharePointListField, SharePointListsClient } from '../services/sharePointLists';
export interface SharePointFieldsState {
items: SharePointListField[];
isLoading: boolean;
error?: string;
}
/** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */
export function useSharePointFields(
client: SharePointListsClient | undefined,
siteUrl: string | undefined | null,
listTitle: string | undefined | null
): SharePointFieldsState {
const [items, setItems] = React.useState<SharePointListField[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
if (!client || !siteUrl?.trim() || !listTitle?.trim()) {
setItems([]);
setError(undefined);
setIsLoading(false);
return undefined;
}
let cancelled = false;
setIsLoading(true);
setError(undefined);
client.listFields(siteUrl.trim(), listTitle.trim())
.then(result => { if (!cancelled) setItems(result); })
.catch((e: Error) => { if (!cancelled) { setItems([]); setError(e.message); } })
.finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; };
}, [client, siteUrl, listTitle]);
return { items, isLoading, error };
}
@@ -0,0 +1,45 @@
import * as React from 'react';
import { SharePointList, SharePointListsClient } from '../services/sharePointLists';
export interface SharePointListsState {
items: SharePointList[];
isLoading: boolean;
error?: string;
}
const DEBOUNCE_MS = 500;
/**
* Списки указанного сайта SharePoint — подгружаются автоматически по мере ввода
* site_url (с задержкой, чтобы не дёргать сайт на каждый символ).
*/
export function useSharePointLists(client: SharePointListsClient | undefined, siteUrl: string): SharePointListsState {
const [items, setItems] = React.useState<SharePointList[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
const trimmed = siteUrl.trim();
if (!client || !trimmed) {
setItems([]);
setError(undefined);
setIsLoading(false);
return undefined;
}
let cancelled = false;
setIsLoading(true);
setError(undefined);
const timer = setTimeout(() => {
client.listLists(trimmed)
.then(result => { if (!cancelled) setItems(result); })
.catch((e: Error) => { if (!cancelled) { setItems([]); setError(e.message); } })
.finally(() => { if (!cancelled) setIsLoading(false); });
}, DEBOUNCE_MS);
return () => { cancelled = true; clearTimeout(timer); };
}, [client, siteUrl]);
return { items, isLoading, error };
}
@@ -28,3 +28,10 @@ export enum HeadType {
DirectionHead = 'DirectionHead', DirectionHead = 'DirectionHead',
DirectorTansu = 'DirectorTansu' DirectorTansu = 'DirectorTansu'
} }
export enum ComparisonOperator {
None = 'None',
GreaterThan = 'GreaterThan',
LessThan = 'LessThan',
Equal = 'Equal'
}
@@ -4,11 +4,19 @@ export interface RequestType {
id: number; id: number;
name: string; name: string;
pageUrl: string; pageUrl: string;
/** Имя списка SharePoint (Title) — источник field_name для шагов маршрута. */
listName: string | null;
/** Сайт SharePoint, где живёт listName. Заполнен тогда и только тогда, когда заполнен listName. */
siteUrl: string | null;
section: string;
} }
export interface CreateRequestTypeInput { export interface CreateRequestTypeInput {
name: string; name: string;
pageUrl: string; pageUrl: string;
listName: string | null;
siteUrl: string | null;
section: string;
} }
export type UpdateRequestTypeInput = CreateRequestTypeInput; export type UpdateRequestTypeInput = CreateRequestTypeInput;
@@ -16,4 +24,7 @@ export type UpdateRequestTypeInput = CreateRequestTypeInput;
export interface PatchRequestTypeInput { export interface PatchRequestTypeInput {
name?: string; name?: string;
pageUrl?: string; pageUrl?: string;
listName?: string | null;
siteUrl?: string | null;
section?: string;
} }
@@ -1,7 +1,7 @@
// DTO эндпоинта api/template-routes/{routeId}/steps // DTO эндпоинта api/template-routes/{routeId}/steps
// (Application/TemplateRouteSteps/*.cs). // (Application/TemplateRouteSteps/*.cs).
import { HeadType, ProjectRole, RouteStepStatus } from './enums'; import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
export interface TemplateRouteStep { export interface TemplateRouteStep {
id: number; id: number;
@@ -13,6 +13,11 @@ export interface TemplateRouteStep {
status: RouteStepStatus; status: RouteStepStatus;
routeId: number; routeId: number;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
/** Столбец списка SharePoint (internal name), по которому проверяется условие. */
fieldName: string | null;
operation: ComparisonOperator | null;
/** Значение для сравнения с fieldName. */
value: string | null;
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */ /** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
warning?: string | null; warning?: string | null;
} }
@@ -25,6 +30,9 @@ export interface CreateTemplateRouteStepInput {
headType: HeadType | null; headType: HeadType | null;
status: RouteStepStatus; status: RouteStepStatus;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
fieldName: string | null;
operation: ComparisonOperator | null;
value: string | null;
} }
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput; export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
@@ -37,6 +45,9 @@ export interface PatchTemplateRouteStepInput {
headType?: HeadType | null; headType?: HeadType | null;
status?: RouteStepStatus; status?: RouteStepStatus;
allowReassignToOtherApprovers?: boolean; allowReassignToOtherApprovers?: boolean;
fieldName?: string | null;
operation?: ComparisonOperator | null;
value?: string | null;
} }
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено // Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
@@ -0,0 +1,61 @@
import { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/fields';
import { WebPartContext } from '@microsoft/sp-webpart-base';
// Списки, из которых типы заявок берут list_name/field_name, живут на произвольном
// сайте SharePoint (разном для разных типов заявок) — не обязательно на том, где
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
// См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт».
export interface SharePointList {
id: string;
title: string;
}
export interface SharePointListField {
internalName: string;
title: string;
}
interface IListItem {
Id: string;
Title: string;
}
interface IFieldItem {
InternalName: string;
Title: string;
}
function clientFor(context: WebPartContext, siteUrl: string): SPFI {
return spfi(siteUrl).using(SPFx(context));
}
export class SharePointListsClient {
constructor(private readonly context: WebPartContext) {}
/** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */
public async listLists(siteUrl: string): Promise<SharePointList[]> {
const sp = clientFor(this.context, siteUrl);
const items = await sp.web.lists
.select('Id', 'Title')
.filter('Hidden eq false and BaseTemplate eq 100')
.orderBy('Title')() as IListItem[];
return items.map(x => ({ id: x.Id, title: x.Title }));
}
/** Заголовки столбцов (внутреннее + отображаемое имя) списка — исключая служебные поля. */
public async listFields(siteUrl: string, listTitle: string): Promise<SharePointListField[]> {
const sp = clientFor(this.context, siteUrl);
const items = await sp.web.lists.getByTitle(listTitle).fields
.select('InternalName', 'Title')
.filter('Hidden eq false and ReadOnlyField eq false')
.orderBy('Title')() as IFieldItem[];
return items.map(x => ({ internalName: x.InternalName, title: x.Title }));
}
}
+34 -2
View File
@@ -4,7 +4,7 @@
// //
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались. // Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
import { HeadType, ProjectRole, RouteStepStatus, RouteType } from './models'; import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus, RouteType } from './models';
import { pluralRu } from './utils/plural'; import { pluralRu } from './utils/plural';
// ---- Подписи enum'ов ---- // ---- Подписи enum'ов ----
@@ -38,6 +38,13 @@ export const headTypeLabels: Record<HeadType, string> = {
[HeadType.DirectorTansu]: 'Директор Tansu' [HeadType.DirectorTansu]: 'Директор Tansu'
}; };
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '—',
[ComparisonOperator.GreaterThan]: 'Больше (>)',
[ComparisonOperator.LessThan]: 'Меньше (<)',
[ComparisonOperator.Equal]: 'Равно (=)'
};
export const texts = { export const texts = {
common: { common: {
home: 'Главная', home: 'Главная',
@@ -96,6 +103,7 @@ export const texts = {
emptyList: 'Типов заявок пока нет.', emptyList: 'Типов заявок пока нет.',
columnName: 'Название', columnName: 'Название',
columnPageUrl: 'Url страницы', columnPageUrl: 'Url страницы',
columnSection: 'Раздел',
createTitle: 'Новый тип заявки', createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки', editTitle: 'Редактировать тип заявки',
nameLabel: 'Название', nameLabel: 'Название',
@@ -103,7 +111,19 @@ export const texts = {
pageUrlLabel: 'Url страницы', pageUrlLabel: 'Url страницы',
pageUrlHint: 'Страница формы заявки на портале.', pageUrlHint: 'Страница формы заявки на портале.',
pageUrlPlaceholder: '/pages/business-trip', pageUrlPlaceholder: '/pages/business-trip',
validationRequired: 'Заполните имя и Url страницы.', sectionLabel: 'Раздел',
sectionPlaceholder: 'Например, Кадры',
siteUrlLabel: 'Сайт SharePoint',
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
listNameLabel: 'Список SharePoint',
listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.',
listNamePlaceholder: 'Сначала укажите сайт SharePoint',
listNameLoading: 'Загрузка списков сайта...',
listNameEmpty: 'На этом сайте нет доступных списков',
listNameError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
validationRequired: 'Заполните имя, Url страницы и раздел.',
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
deleteTitle: 'Удалить тип заявки?', deleteTitle: 'Удалить тип заявки?',
deleteText: (name: string): string => deleteText: (name: string): string =>
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.` `Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
@@ -187,9 +207,21 @@ export const texts = {
headTypeLabel: 'Тип руководителя', headTypeLabel: 'Тип руководителя',
reassignLabel: 'Переназначение', reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим', reassignCheckbox: 'Разрешить переназначать другим согласующим',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
conditionFieldNameLabel: 'Поле списка',
conditionFieldNamePlaceholder: 'Выберите столбец списка',
conditionFieldNameLoading: 'Загрузка столбцов списка...',
conditionFieldNameEmpty: 'В списке нет доступных столбцов',
conditionFieldNameError: (message: string): string => `Не удалось получить столбцы списка: ${message}`,
conditionOperationLabel: 'Оператор сравнения',
conditionValueLabel: 'Значение',
conditionValuePlaceholder: 'Например, 1000',
validationOrderNo: 'Порядковый номер шага обязателен.', validationOrderNo: 'Порядковый номер шага обязателен.',
validationExecutor: 'Выберите сотрудника-исполнителя.', validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.', validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.',
deleteTitle: 'Удалить шаг?', deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
}, },