фронт поправлен
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<IReadOnlyList<RequestTypeDto>>(items);
|
||||
@@ -29,11 +29,16 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
|
||||
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
|
||||
{
|
||||
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<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||
|
||||
if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl))
|
||||
return Result.Fail<RequestTypeDto>(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<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.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);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator<Create
|
||||
RuleFor(x => 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<Update
|
||||
RuleFor(x => 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<PatchRe
|
||||
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 PageUrl { get; set; } = string.Empty;
|
||||
public string? ListName { get; set; }
|
||||
public string? SiteUrl { get; set; }
|
||||
public string Section { get; set; } = string.Empty;
|
||||
|
||||
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.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();
|
||||
});
|
||||
|
||||
|
||||
+6
-1
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -57,6 +57,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("section");
|
||||
|
||||
b.Property<string>("SiteUrl")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("site_url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_request_types");
|
||||
|
||||
+11
@@ -45,6 +45,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
maxLength: 256,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "site_url",
|
||||
table: "request_types",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -54,6 +54,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("section");
|
||||
|
||||
b.Property<string>("SiteUrl")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("site_url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_request_types");
|
||||
|
||||
|
||||
+35
-9
@@ -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);
|
||||
|
||||
+5
-5
@@ -38,7 +38,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
}
|
||||
|
||||
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 возвращает сохранённые данные")]
|
||||
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
|
||||
@@ -196,7 +196,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||
|
||||
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<PostgresDatabaseFi
|
||||
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||
|
||||
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<PostgresDatabaseFi
|
||||
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||
|
||||
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<PostgresDatabaseFi
|
||||
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||
|
||||
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);
|
||||
|
||||
+2
-1
@@ -26,8 +26,9 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
||||
|
||||
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(
|
||||
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),
|
||||
|
||||
Generated
+71
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<IApprovalAdminProps> = props => {
|
||||
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
|
||||
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]);
|
||||
|
||||
React.useEffect(() => {
|
||||
ensureFontsLoaded();
|
||||
@@ -80,6 +82,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
||||
<AdminApp
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
sharePoint={sharePoint}
|
||||
currentUserEmail={props.context.pageContext.user.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
|
||||
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
|
||||
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState('');
|
||||
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
||||
|
||||
const navigate = (key: ScreenKey): void => {
|
||||
setScreen(key);
|
||||
@@ -29,9 +31,10 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ 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<IAdminAppProps> = ({ api, dictionary, currentUse
|
||||
<TopNav active={screen} onSelect={navigate} />
|
||||
<div className={styles.content}>
|
||||
{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 === 'routeSteps' && selectedRoute && (
|
||||
<RouteStepsScreen
|
||||
api={api}
|
||||
route={selectedRoute}
|
||||
requestTypeName={selectedRequestTypeName}
|
||||
requestType={selectedRequestType}
|
||||
dictionary={dictionary}
|
||||
sharePoint={sharePoint}
|
||||
onBack={() => setScreen('routes')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<IRequestTypesScreenProps> = ({ api }) => {
|
||||
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
|
||||
const list = useRequestTypes(api);
|
||||
|
||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||
const submit = useAsyncSubmit();
|
||||
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
||||
|
||||
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 => {
|
||||
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<IRequestTypesScreenProps> = ({ api })
|
||||
);
|
||||
};
|
||||
|
||||
const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
|
||||
@@ -87,6 +116,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
|
||||
columns={[
|
||||
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||
{ 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<IRequestTypesScreenProps> = ({ api })
|
||||
placeholder={texts.requestTypes.pageUrlPlaceholder}
|
||||
/>
|
||||
</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>
|
||||
</FormModal>
|
||||
)}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
Employee,
|
||||
HeadType,
|
||||
ProjectRole,
|
||||
RequestType,
|
||||
RouteStepStatus,
|
||||
StepExecutorKind,
|
||||
TemplateRoute,
|
||||
TemplateRouteStep
|
||||
} from '../../models';
|
||||
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { SharePointListsClient } from '../../services/sharePointLists';
|
||||
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
|
||||
|
||||
export interface IRouteStepFormModalProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
/** Undefined, пока грузится тип заявки маршрута. */
|
||||
requestType?: RequestType;
|
||||
sharePoint: SharePointListsClient;
|
||||
/** Undefined — создание нового шага, иначе редактирование существующего. */
|
||||
editing?: TemplateRouteStep;
|
||||
nextOrderNo: number;
|
||||
@@ -36,6 +43,10 @@ interface IFormState {
|
||||
projectRole: ProjectRole;
|
||||
headType: HeadType;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
hasCondition: boolean;
|
||||
fieldName: string;
|
||||
operation: ComparisonOperator;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||
@@ -49,6 +60,10 @@ const HEAD_TYPE_OPTIONS = Object.values(HeadType)
|
||||
.filter(v => v !== HeadType.None)
|
||||
.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 {
|
||||
if (!editing) {
|
||||
return {
|
||||
@@ -59,7 +74,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
||||
position: '',
|
||||
projectRole: ProjectRole.ProjectManager,
|
||||
headType: HeadType.Chief,
|
||||
allowReassignToOtherApprovers: false
|
||||
allowReassignToOtherApprovers: false,
|
||||
hasCondition: false,
|
||||
fieldName: '',
|
||||
operation: ComparisonOperator.GreaterThan,
|
||||
value: ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -70,7 +89,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
||||
position: editing.position ?? '',
|
||||
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
|
||||
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> = ({
|
||||
api,
|
||||
route,
|
||||
requestType,
|
||||
sharePoint,
|
||||
editing,
|
||||
nextOrderNo,
|
||||
employees,
|
||||
@@ -87,6 +112,16 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
}) => {
|
||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||
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 orderNo = Number(form.orderNo);
|
||||
@@ -102,6 +137,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
submit.fail(texts.routeSteps.validationPosition);
|
||||
return;
|
||||
}
|
||||
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) {
|
||||
submit.fail(texts.routeSteps.validationCondition);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
orderNo,
|
||||
@@ -110,7 +149,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
|
||||
position: form.kind === 'position' ? form.position.trim() : 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
|
||||
@@ -217,6 +259,54 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
|
||||
/>
|
||||
</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>
|
||||
</FormModal>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@ import * as React from 'react';
|
||||
import { RouteStepFormModal } from './RouteStepFormModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
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 { describeStepExecutor } from '../../utils/stepExecutor';
|
||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||
@@ -25,16 +26,19 @@ import {
|
||||
export interface IRouteStepsScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
requestTypeName: string;
|
||||
/** Undefined, пока грузится тип заявки (см. AdminApp.openSteps). */
|
||||
requestType?: RequestType;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
sharePoint: SharePointListsClient;
|
||||
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 employees = usePortalEmployees(dictionary);
|
||||
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
|
||||
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
@@ -140,6 +144,8 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
<RouteStepFormModal
|
||||
api={api}
|
||||
route={route}
|
||||
requestType={requestType}
|
||||
sharePoint={sharePoint}
|
||||
editing={editing}
|
||||
nextOrderNo={nextOrderNo}
|
||||
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',
|
||||
DirectorTansu = 'DirectorTansu'
|
||||
}
|
||||
|
||||
export enum ComparisonOperator {
|
||||
None = 'None',
|
||||
GreaterThan = 'GreaterThan',
|
||||
LessThan = 'LessThan',
|
||||
Equal = 'Equal'
|
||||
}
|
||||
|
||||
@@ -4,11 +4,19 @@ export interface RequestType {
|
||||
id: number;
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
/** Имя списка SharePoint (Title) — источник field_name для шагов маршрута. */
|
||||
listName: string | null;
|
||||
/** Сайт SharePoint, где живёт listName. Заполнен тогда и только тогда, когда заполнен listName. */
|
||||
siteUrl: string | null;
|
||||
section: string;
|
||||
}
|
||||
|
||||
export interface CreateRequestTypeInput {
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
listName: string | null;
|
||||
siteUrl: string | null;
|
||||
section: string;
|
||||
}
|
||||
|
||||
export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
||||
@@ -16,4 +24,7 @@ export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
||||
export interface PatchRequestTypeInput {
|
||||
name?: string;
|
||||
pageUrl?: string;
|
||||
listName?: string | null;
|
||||
siteUrl?: string | null;
|
||||
section?: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// DTO эндпоинта api/template-routes/{routeId}/steps
|
||||
// (Application/TemplateRouteSteps/*.cs).
|
||||
|
||||
import { HeadType, ProjectRole, RouteStepStatus } from './enums';
|
||||
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
|
||||
|
||||
export interface TemplateRouteStep {
|
||||
id: number;
|
||||
@@ -13,6 +13,11 @@ export interface TemplateRouteStep {
|
||||
status: RouteStepStatus;
|
||||
routeId: number;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
/** Столбец списка SharePoint (internal name), по которому проверяется условие. */
|
||||
fieldName: string | null;
|
||||
operation: ComparisonOperator | null;
|
||||
/** Значение для сравнения с fieldName. */
|
||||
value: string | null;
|
||||
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
|
||||
warning?: string | null;
|
||||
}
|
||||
@@ -25,6 +30,9 @@ export interface CreateTemplateRouteStepInput {
|
||||
headType: HeadType | null;
|
||||
status: RouteStepStatus;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
fieldName: string | null;
|
||||
operation: ComparisonOperator | null;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
|
||||
@@ -37,6 +45,9 @@ export interface PatchTemplateRouteStepInput {
|
||||
headType?: HeadType | null;
|
||||
status?: RouteStepStatus;
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
|
||||
|
||||
import { HeadType, ProjectRole, RouteStepStatus, RouteType } from './models';
|
||||
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus, RouteType } from './models';
|
||||
import { pluralRu } from './utils/plural';
|
||||
|
||||
// ---- Подписи enum'ов ----
|
||||
@@ -38,6 +38,13 @@ export const headTypeLabels: Record<HeadType, string> = {
|
||||
[HeadType.DirectorTansu]: 'Директор Tansu'
|
||||
};
|
||||
|
||||
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
|
||||
[ComparisonOperator.None]: '—',
|
||||
[ComparisonOperator.GreaterThan]: 'Больше (>)',
|
||||
[ComparisonOperator.LessThan]: 'Меньше (<)',
|
||||
[ComparisonOperator.Equal]: 'Равно (=)'
|
||||
};
|
||||
|
||||
export const texts = {
|
||||
common: {
|
||||
home: 'Главная',
|
||||
@@ -96,6 +103,7 @@ export const texts = {
|
||||
emptyList: 'Типов заявок пока нет.',
|
||||
columnName: 'Название',
|
||||
columnPageUrl: 'Url страницы',
|
||||
columnSection: 'Раздел',
|
||||
createTitle: 'Новый тип заявки',
|
||||
editTitle: 'Редактировать тип заявки',
|
||||
nameLabel: 'Название',
|
||||
@@ -103,7 +111,19 @@ export const texts = {
|
||||
pageUrlLabel: 'Url страницы',
|
||||
pageUrlHint: 'Страница формы заявки на портале.',
|
||||
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: 'Удалить тип заявки?',
|
||||
deleteText: (name: string): string =>
|
||||
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
|
||||
@@ -187,9 +207,21 @@ export const texts = {
|
||||
headTypeLabel: 'Тип руководителя',
|
||||
reassignLabel: 'Переназначение',
|
||||
reassignCheckbox: 'Разрешить переназначать другим согласующим',
|
||||
conditionLabel: 'Условие видимости шага',
|
||||
conditionCheckbox: 'Показывать шаг только при выполнении условия',
|
||||
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
|
||||
conditionFieldNameLabel: 'Поле списка',
|
||||
conditionFieldNamePlaceholder: 'Выберите столбец списка',
|
||||
conditionFieldNameLoading: 'Загрузка столбцов списка...',
|
||||
conditionFieldNameEmpty: 'В списке нет доступных столбцов',
|
||||
conditionFieldNameError: (message: string): string => `Не удалось получить столбцы списка: ${message}`,
|
||||
conditionOperationLabel: 'Оператор сравнения',
|
||||
conditionValueLabel: 'Значение',
|
||||
conditionValuePlaceholder: 'Например, 1000',
|
||||
validationOrderNo: 'Порядковый номер шага обязателен.',
|
||||
validationExecutor: 'Выберите сотрудника-исполнителя.',
|
||||
validationPosition: 'Укажите должность.',
|
||||
validationCondition: 'Заполните поле списка, оператор и значение условия.',
|
||||
deleteTitle: 'Удалить шаг?',
|
||||
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user