10 Commits

Author SHA1 Message Date
gorbenko.pavel 35ec66087e переделка под мой фронтенд
Tests / test (pull_request) Successful in 2m48s
2026-07-31 17:07:23 +05:00
gorbenko.pavel 78a5362aeb деплои файлы 2026-07-31 17:00:03 +05:00
gorbenko.pavel 05d7fd1e3f удалены не нужные картинки 2026-07-31 16:40:25 +05:00
gorbenko.pavel 14b25bf8cc компания необзятаельна 2026-07-31 16:22:12 +05:00
gorbenko.pavel 58258b0126 правка списка 2026-07-31 15:49:28 +05:00
gorbenko.pavel e5b4af63fd в резолв добавлена новая логика 2026-07-31 15:34:45 +05:00
gorbenko.pavel 8a0bb146e9 рефакторинг стилей 2026-07-31 15:09:15 +05:00
gorbenko.pavel 7566da06c2 правочки 2026-07-31 14:50:33 +05:00
gorbenko.pavel de60fd0fdb паралельный последовательный удалены 2026-07-31 14:38:31 +05:00
gorbenko.pavel d38962754d всякие фиксы 2026-07-31 14:20:13 +05:00
42 changed files with 1064 additions and 339 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Build & Deploy to SharePoint Online
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js 22
uses: actions/setup-node@v3
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: |
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 10000
npm config set fetch-retry-maxtimeout 120000
for i in 1 2 3; do
npm install --legacy-peer-deps && break
echo "Attempt $i failed, retrying in 20s..."
sleep 20
done
- name: Build & package SPFx solution
run: npm run build
- name: Upload .sppkg artifact
uses: actions/upload-artifact@v3
with:
name: approval-route-admin-sppkg
path: frontend/sharepoint/solution/approval-route-admin.sppkg
retention-days: 7
- name: Install CLI for Microsoft 365
run: |
for i in 1 2 3; do
npm install -g @pnp/cli-microsoft365@9 && break
echo "Attempt $i failed, retrying in 20s..."
sleep 20
done
- name: Login to Microsoft 365 (certificate)
run: |
m365 login \
--authType certificate \
--appId "${{ secrets.SPO_CLIENT_ID }}" \
--certificateBase64Encoded "${{ secrets.SPO_CERT_BASE64 }}" \
--thumbprint "${{ secrets.SPO_CERT_THUMBPRINT }}" \
--tenant "${{ secrets.SPO_TENANT_ID }}"
- name: Upload & Deploy to App Catalog
env:
SOLUTION_ID: "ce823ddf-e35c-460f-a92d-ddb4ef5fc494"
CATALOG_SITE: "https://tnsukz.sharepoint.com/sites/appcatalog"
SPPKG: "sharepoint/solution/approval-route-admin.sppkg"
FILE_NAME: "approval-route-admin.sppkg"
run: |
# Получаем токен через уже аутентифицированный m365 CLI
TOKEN=$(m365 util accesstoken get --resource https://tnsukz.sharepoint.com --output text)
echo "Token length: ${#TOKEN}"
echo ">>> Uploading to App Catalog"
HTTP=$(curl -s -o /tmp/upload.json -w "%{http_code}" -X POST \
"${CATALOG_SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/appcatalog/AppCatalog')/Files/add(overwrite=true,url='${FILE_NAME}')" \
-H "Authorization: Bearer ${TOKEN}" \
-H "X-RequestDigest: 0x0" \
-H "Accept: application/json;odata=nometadata" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${SPPKG}" || echo "000")
echo "Upload HTTP: ${HTTP}"
cat /tmp/upload.json
[ "$HTTP" = "200" ] || [ "$HTTP" = "201" ] || exit 1
echo ">>> Deploying app (id: ${SOLUTION_ID})"
HTTP=$(curl -s -o /tmp/deploy.json -w "%{http_code}" -X POST \
"${CATALOG_SITE}/_api/web/tenantappcatalog/AvailableApps/GetById('${SOLUTION_ID}')/deploy" \
-H "Authorization: Bearer ${TOKEN}" \
-H "X-RequestDigest: 0x0" \
-H "Accept: application/json;odata=nometadata" \
-H "Content-Type: application/json" \
-d '{"skipFeatureDeployment":true}' || echo "000")
echo "Deploy HTTP: ${HTTP}"
cat /tmp/deploy.json
[ "$HTTP" = "200" ] || [ "$HTTP" = "204" ] || exit 1
echo ">>> Done"
@@ -11,11 +11,14 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[Produces("application/json")]
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
{
[HttpGet("resolve")]
// POST, а не GET: запрос принимает список условий (field_name/value) переменной длины,
// который не ложится на плоскую query-строку без индексной эквилибристики. Единственный
// потребитель — диагностический экран «Проверка маршрута» в админке, внешних вызовов нет.
[HttpPost("resolve")]
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
public async Task<IActionResult> Resolve([FromBody] ResolveTemplateRouteRequest request, CancellationToken ct) =>
(await service.ResolveAsync(request, ct)).ToActionResult();
[HttpGet]
@@ -12,12 +12,15 @@ public sealed record ResolvedRouteStepDto(
public sealed record ResolveTemplateRouteResultDto(
int RouteId,
RouteType RouteType,
bool RequiresControl,
IReadOnlyList<ResolvedRouteStepDto> Steps);
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
public sealed record ResolveFieldValueDto(string FieldName, string Value);
public sealed record ResolveTemplateRouteRequest(
int RequestTypeId,
string? UserEmail,
Guid? ProjectId,
RouteStepStatus Status);
RouteStepStatus Status,
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null);
@@ -1,33 +1,27 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record TemplateRouteDto(
int Id,
RouteType RouteType,
int RequestTypeId,
Guid? DepartmentId,
Guid CompanyId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record CreateTemplateRouteRequest(
RouteType RouteType,
int RequestTypeId,
Guid? DepartmentId,
Guid CompanyId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record UpdateTemplateRouteRequest(
RouteType RouteType,
Guid? DepartmentId,
Guid CompanyId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record PatchTemplateRouteRequest(
RouteType? RouteType,
Guid? DepartmentId,
Guid? CompanyId,
bool? ByProject,
@@ -1,3 +1,4 @@
using System.Globalization;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@@ -24,7 +25,7 @@ public sealed class TemplateRouteService(
var items = await query
.Select(x => new TemplateRouteDto(
x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
x.Id, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
@@ -52,7 +53,6 @@ public sealed class TemplateRouteService(
var entity = new TemplateRoute
{
RouteType = r.RouteType,
RequestTypeId = r.RequestTypeId,
DepartmentId = r.DepartmentId,
CompanyId = r.CompanyId,
@@ -76,7 +76,6 @@ public sealed class TemplateRouteService(
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType;
entity.DepartmentId = r.DepartmentId;
entity.CompanyId = r.CompanyId;
entity.ByProject = r.ByProject;
@@ -101,7 +100,6 @@ public sealed class TemplateRouteService(
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType ?? entity.RouteType;
entity.DepartmentId = departmentId;
entity.CompanyId = companyId;
entity.ByProject = byProject;
@@ -142,22 +140,11 @@ public sealed class TemplateRouteService(
var byProject = request.ProjectId is not null;
var route = await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.DepartmentId == employee.DepartmentId)
.FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken);
// Приоритет — маршрут с компанией инициатора; если для этой компании
// своего маршрута нет, берём запасной маршрут без указанной компании
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
@@ -168,8 +155,14 @@ public sealed class TemplateRouteService(
if (request.ProjectId is { } projectId)
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
var fieldValues = (request.FieldValues ?? [])
.Where(f => !string.IsNullOrWhiteSpace(f.FieldName))
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
var matchingSteps = route.Steps
.Where(s => s.Status == request.Status)
.Where(s => MatchesCondition(s, fieldValues))
.OrderBy(s => s.OrderNo)
.ToList();
@@ -179,11 +172,36 @@ public sealed class TemplateRouteService(
var collapsedSteps = CollapseByExecutorEmail(steps);
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps));
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
}
/// <summary>
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
/// (для той же компании). Компания при этом фиксирована — фолбэк по ней делает
/// вызывающий код (см. ResolveAsync: сначала с companyId сотрудника, затем null).
/// </summary>
private async Task<TemplateRoute?> FindRouteAsync(
int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) =>
await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == requestTypeId &&
x.CompanyId == companyId &&
x.ByProject == byProject &&
x.DepartmentId == departmentId)
.FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == requestTypeId &&
x.CompanyId == companyId &&
x.ByProject == byProject &&
x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken);
private Task<bool> HasDuplicateAsync(
int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
int requestTypeId, Guid? departmentId, Guid? companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
db.TemplateRoutes.AnyAsync(x =>
x.Id != excludeId &&
x.RequestTypeId == requestTypeId &&
@@ -243,6 +261,37 @@ public sealed class TemplateRouteService(
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning);
}
/// <summary>
/// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе.
/// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля
/// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный
/// сценарий, и отсутствие значения означает, что условие не выполняется.
/// </summary>
private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
{
if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None)
return true;
if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
return false;
var expected = step.Value;
return step.Operation switch
{
ComparisonOperator.Equal =>
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected),
ComparisonOperator.GreaterThan =>
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
ComparisonOperator.LessThan =>
TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected,
_ => true
};
}
private static bool TryParseNumber(string? value, out double number) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
{
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -260,5 +309,5 @@ public sealed class TemplateRouteService(
}
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
}
@@ -3,35 +3,20 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<CreateTemplateRouteRequest>
{
public CreateTemplateRouteRequestValidator()
{
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
}
}
public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<UpdateTemplateRouteRequest>
{
public UpdateTemplateRouteRequestValidator()
{
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
}
}
public sealed class PatchTemplateRouteRequestValidator : AbstractValidator<PatchTemplateRouteRequest>
{
public PatchTemplateRouteRequestValidator()
{
When(x => x.CompanyId is not null, () =>
RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна."));
}
}
// Компания и отдел маршрута — оба опциональны, поэтому CreateTemplateRouteRequest/
// UpdateTemplateRouteRequest/PatchTemplateRouteRequest не нуждаются в отдельных
// валидаторах: маршрут без компании служит запасным вариантом, когда для
// конкретной компании инициатора своего маршрута нет (см. ResolveAsync).
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
{
public ResolveTemplateRouteRequestValidator()
{
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleForEach(x => x.FieldValues).ChildRules(v =>
{
v.RuleFor(f => f.FieldName).NotEmpty().WithMessage("Имя поля обязательно.");
v.RuleFor(f => f.Value).NotEmpty().WithMessage("Значение поля обязательно.");
});
}
}
@@ -1,15 +1,12 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Domain.Entities;
public class TemplateRoute
{
public int Id { get; set; }
public RouteType RouteType { get; set; } = RouteType.Sequential;
public int RequestTypeId { get; set; }
public Guid? DepartmentId { get; set; }
public Guid CompanyId { get; set; }
public Guid? CompanyId { get; set; }
public bool ByProject { get; set; }
public bool RequiresControl { get; set; }
@@ -1,8 +0,0 @@
namespace Tnsu.ApprovalRoute.Domain.Enums;
public enum RouteType
{
None,
Parallel,
Sequential
}
@@ -24,7 +24,6 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
modelBuilder.Entity<TemplateRoute>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired();
e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId);
e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique();
});
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260731073536_AddRequestTypeListNameSectionAndStepConditionFields")]
[Migration("20260731111928_AddRequestTypeListNameSectionAndStepConditionFields")]
partial class AddRequestTypeListNameSectionAndStepConditionFields
{
/// <inheritdoc />
@@ -81,7 +81,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid>("CompanyId")
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
@@ -97,12 +97,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.Property<string>("RouteType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("route_type");
b.HasKey("Id")
.HasName("pk_template_routes");
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -10,6 +11,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "route_type",
table: "template_routes");
migrationBuilder.AlterColumn<Guid>(
name: "company_id",
table: "template_routes",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>(
name: "field_name",
table: "template_route_steps",
@@ -80,6 +93,24 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
migrationBuilder.DropColumn(
name: "site_url",
table: "request_types");
migrationBuilder.AlterColumn<Guid>(
name: "company_id",
table: "template_routes",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "route_type",
table: "template_routes",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
}
}
}
@@ -78,7 +78,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid>("CompanyId")
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
@@ -94,12 +94,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.Property<string>("RouteType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("route_type");
b.HasKey("Id")
.HasName("pk_template_routes");
@@ -40,6 +40,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
private async Task<int> CreateRequestTypeAsync(string name) =>
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id;
private async Task<int> CreateRequestTypeWithListAsync(string name) =>
(await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(name, $"/{name}", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"),
CancellationToken.None)).Value.Id;
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
{
@@ -48,7 +53,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Action
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
// Assert
@@ -65,7 +70,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{
// Action
var result = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, 999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
new CreateTemplateRouteRequest(999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Assert
@@ -77,7 +82,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Business trip");
var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false);
var request = new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false);
var first = await _service.CreateAsync(request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
@@ -94,18 +99,17 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Sick leave");
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var updated = await _service.UpdateAsync(
created.Value.Id,
new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
new UpdateTemplateRouteRequest(DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
// Assert
updated.IsSuccess.ShouldBeTrue();
updated.Value.RouteType.ShouldBe(RouteType.Parallel);
updated.Value.RequiresControl.ShouldBeTrue();
}
@@ -115,18 +119,17 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Patch route request");
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var patched = await _service.PatchAsync(
created.Value.Id,
new PatchTemplateRouteRequest(RouteType.Parallel, DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true),
new PatchTemplateRouteRequest(DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true),
CancellationToken.None);
// Assert
patched.IsSuccess.ShouldBeTrue();
patched.Value.RouteType.ShouldBe(RouteType.Parallel);
patched.Value.RequiresControl.ShouldBeTrue();
patched.Value.CompanyId.ShouldBe(CompanyId);
patched.Value.DepartmentId.ShouldBe(DepartmentId);
@@ -138,7 +141,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Remote work");
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
@@ -157,7 +160,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Exact match request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -198,7 +201,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var requestTypeId = (await requestTypeService.CreateAsync(
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),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
@@ -228,13 +231,123 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
}
[Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")]
public async Task ResolveAsync_ConditionGreaterThan_SatisfiedByFieldValue_IncludesStep()
{
// Arrange
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request satisfied");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
[new ResolveFieldValueDto("Amount", "1500")]),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("big-amount@tnsu.kz");
}
[Fact(DisplayName = "Резолв маршрута исключает шаг с условием, когда значение поля его не удовлетворяет")]
public async Task ResolveAsync_ConditionGreaterThan_NotSatisfiedByFieldValue_ExcludesStep()
{
// Arrange
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request not satisfied");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
[new ResolveFieldValueDto("Amount", "500")]),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("always@tnsu.kz");
}
[Fact(DisplayName = "Резолв маршрута исключает шаг с условием, если значение поля вообще не передано")]
public async Task ResolveAsync_ConditionField_MissingFromRequest_ExcludesStep()
{
// Arrange
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request missing value");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Action — fieldValues не переданы вовсе
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldBeEmpty();
}
[Fact(DisplayName = "Резолв маршрута с условием «равно» сравнивает значения как строки без учёта регистра")]
public async Task ResolveAsync_ConditionEqual_MatchesCaseInsensitive()
{
// Arrange
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request equal");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", ComparisonOperator.Equal, "Urgent"),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
[new ResolveFieldValueDto("Priority", "URGENT")]),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("urgent@tnsu.kz");
}
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Project request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -274,7 +387,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var requestTypeId = (await requestTypeService.CreateAsync(
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),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
@@ -313,7 +426,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var requestTypeId = (await requestTypeService.CreateAsync(
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),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
@@ -348,7 +461,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var requestTypeId = (await requestTypeService.CreateAsync(
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),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
@@ -373,7 +486,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Chief not found request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -401,7 +514,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Position not found request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -429,7 +542,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Project role not found request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -457,7 +570,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false),
new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
@@ -470,6 +583,48 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.RouteId.ShouldBe(route.Value.Id);
}
[Fact(DisplayName = "Резолв маршрута без компании инициатора использует запасной маршрут без указанной компании")]
public async Task ResolveAsync_NoCompanyRoute_FallsBackToRouteWithoutCompany()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("No company route request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId: null, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.RouteId.ShouldBe(route.Value.Id);
}
[Fact(DisplayName = "Резолв маршрута предпочитает маршрут с компанией инициатора маршруту без компании")]
public async Task ResolveAsync_RouteWithCompanyExists_PreferredOverRouteWithoutCompany()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Company priority request");
var companyWideRoute = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId: null, ByProject: false, RequiresControl: false),
CancellationToken.None);
var companySpecificRoute = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.RouteId.ShouldBe(companySpecificRoute.Value.Id);
resolved.Value.RouteId.ShouldNotBe(companyWideRoute.Value.Id);
}
[Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")]
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult()
{
@@ -31,7 +31,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
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),
new CreateTemplateRouteRequest(requestTypeId, null, CompanyId, byProject, RequiresControl: false),
CancellationToken.None);
return route.Value.Id;
+7 -8
View File
@@ -106,19 +106,18 @@ $sp-7: 28px; $sp-8: 32px;
}
// ---- Готовые миксины ----
// Светлая карточка заголовка страницы — сознательно НЕ грд-header (тёмный,
// как TopNav): два одинаковых тёмно-синих блока подряд читаются как один
// тяжёлый слипшийся элемент. Золотая полоса сверху — единственный акцент,
// вместо заливки всей карточки.
@mixin page-header {
background: $grad-header;
background: #fff;
border: 1px solid $gray-200;
border-top: 3px solid $gold-500;
border-radius: $r-lg;
padding: 26px 30px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: linear-gradient(90deg, $gold-500, transparent 60%);
}
}
@mixin card-hover-gold {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -56,7 +56,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
/>
)}
{screen === 'resolve' && (
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} />
<ResolveScreen api={api} dictionary={dictionary} sharePoint={sharePoint} currentUserEmail={currentUserEmail} />
)}
</div>
</div>
@@ -23,20 +23,6 @@
flex-shrink: 0;
}
.mark {
width: 30px;
height: 30px;
border-radius: 7px;
background: $grad-gold-v;
display: flex;
align-items: center;
justify-content: center;
color: $navy-950;
font-weight: 800;
font-size: 13px;
font-family: $font-serif;
}
.brandText {
color: #fff;
font-weight: 600;
@@ -72,7 +72,6 @@ export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
return (
<nav className={styles.topnav}>
<div className={styles.brand}>
<div className={styles.mark}>{texts.nav.markLetter}</div>
<div className={styles.brandText}>{texts.nav.title}</div>
</div>
<div className={styles.tabs} ref={tabsRef}>
@@ -1,15 +1,17 @@
import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api';
import { SharePointListsClient } from '../../services/sharePointLists';
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
import { RequestType } from '../../models';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useSharePointLists } from '../../hooks/useSharePointLists';
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import {
ActionsCell,
Alert,
AsyncSearchableSelect,
Breadcrumbs,
Button,
ConfirmDialog,
@@ -39,6 +41,12 @@ interface IFormState {
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' };
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
const { title } = useSharePointSiteTitle(client, siteUrl);
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
};
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
const list = useRequestTypes(api);
@@ -47,6 +55,11 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
const searchSites = React.useCallback(
(query: string, page: number) => sharePoint.searchSites(query, page),
[sharePoint]
);
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
@@ -117,6 +130,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
{ 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: 'listName', header: texts.requestTypes.columnListName, render: r => r.listName ?? texts.common.empty },
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
{
key: 'actions',
header: '',
@@ -167,10 +182,22 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
/>
</Field>
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
<TextInput
<AsyncSearchableSelect<SharePointSiteSuggestion>
value={form.siteUrl}
onChange={e => setForm({ ...form, siteUrl: e.target.value, listName: '' })}
selectedLabel={siteTitle.title ?? form.siteUrl}
selectedSublabel={siteTitle.title ? form.siteUrl : undefined}
allowClear
clearLabel={texts.select.notSelected}
placeholder={texts.requestTypes.siteUrlPlaceholder}
searchPlaceholder={texts.requestTypes.siteUrlSearchPlaceholder}
search={searchSites}
getValue={s => s.url}
getLabel={s => s.title}
getSublabel={s => s.url}
onChange={(siteUrl, item) => {
if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title);
setForm({ ...form, siteUrl, listName: '' });
}}
/>
</Field>
<Field
@@ -1,3 +1,37 @@
@import '../../../../styles/design-tokens';
.conditions {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid $gray-200;
}
.conditionsHeader {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 12px;
}
.conditionsTitle {
font-size: 13px;
font-weight: 600;
color: $gray-700;
}
.conditionsHint {
font-size: 11.5px;
color: $gray-400;
}
.conditionRow {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.runButtonRow {
margin-top: 18px;
}
@@ -2,11 +2,14 @@ import * as React from 'react';
import styles from './ResolveScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { Project, RouteStepStatus } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Project, ResolveFieldValue, RouteStepStatus } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useResolveRoute } from '../../hooks/useResolveRoute';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
import {
Alert,
@@ -17,30 +20,58 @@ import {
DataTable,
Field,
FormGrid,
IconButton,
PageHeader,
Panel,
PrimaryCell,
SearchableSelect,
Select
Select,
TextInput
} from '../ui';
export interface IResolveScreenProps {
api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
currentUserEmail?: string;
}
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
// Форма проверки маршрута заполняется одними и теми же параметрами раз за разом
// (тип заявки, инициатор, проект, статус) — персистим её в localStorage, чтобы
// не вводить всё заново при каждом открытии экрана.
const RESOLVE_FORM_STORAGE_KEY = 'approvalAdmin.resolveForm';
interface IResolveFormState {
requestTypeId: string;
userEmail: string;
projectId: string;
projectLabel: string;
status: RouteStepStatus;
fieldValues: ResolveFieldValue[];
}
const EMPTY_RESOLVE_FORM: IResolveFormState = {
requestTypeId: '',
userEmail: '',
projectId: '',
projectLabel: '',
status: RouteStepStatus.Approval,
fieldValues: []
};
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
const requestTypes = useRequestTypes(api);
const [requestTypeId, setRequestTypeId] = React.useState('');
const [userEmail, setUserEmail] = React.useState('');
const [projectId, setProjectId] = React.useState('');
const [projectLabel, setProjectLabel] = React.useState('');
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
const hasListName = Boolean(requestType?.listName);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
const resolved = useResolveRoute(api);
const employees = usePortalEmployees(dictionary);
@@ -55,16 +86,35 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
[dictionary]
);
const updateFieldValue = (index: number, patch: Partial<ResolveFieldValue>): void => {
setForm({ ...form, fieldValues: fieldValues.map((fv, i) => (i === index ? { ...fv, ...patch } : fv)) });
};
const addFieldValue = (): void => {
setForm({ ...form, fieldValues: [...fieldValues, { fieldName: '', value: '' }] });
};
const removeFieldValue = (index: number): void => {
setForm({ ...form, fieldValues: fieldValues.filter((_, i) => i !== index) });
};
const run = (): void => {
if (!requestTypeId) {
resolved.fail(texts.resolve.validationRequestType);
return;
}
const conditions = fieldValues.filter(fv => fv.fieldName.trim() || fv.value.trim());
if (conditions.some(fv => !fv.fieldName.trim() || !fv.value.trim())) {
resolved.fail(texts.resolve.validationConditionField);
return;
}
resolved.resolve({
requestTypeId: Number(requestTypeId),
userEmail: userEmail.trim() || undefined,
projectId: projectId.trim() || undefined,
status
status,
fieldValues: conditions.length > 0 ? conditions : undefined
});
};
@@ -83,7 +133,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.resolve.requestTypePlaceholder}
value={requestTypeId}
onChange={e => setRequestTypeId(e.target.value)}
onChange={e => setForm({ ...form, requestTypeId: e.target.value, fieldValues: [] })}
/>
</Field>
<Field
@@ -99,7 +149,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
placeholder={texts.resolve.userEmailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={setUserEmail}
onChange={userEmail => setForm({ ...form, userEmail })}
/>
</Field>
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
@@ -115,16 +165,62 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
getValue={p => p.oid}
getLabel={p => p.name}
getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined}
onChange={(nextId, item) => {
setProjectId(nextId);
setProjectLabel(item ? item.name : '');
}}
onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })}
/>
</Field>
<Field label={texts.resolve.statusLabel} required>
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
<Select
options={STATUS_OPTIONS}
value={status}
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
/>
</Field>
</FormGrid>
<div className={styles.conditions}>
<div className={styles.conditionsHeader}>
<span className={styles.conditionsTitle}>{texts.resolve.conditionsLabel}</span>
{requestTypeId && !hasListName && (
<span className={styles.conditionsHint}>{texts.resolve.conditionsDisabledHint}</span>
)}
</div>
{hasListName && (
<>
{fieldValues.map((fv, index) => {
const field = sharePointFields.items.find(f => f.internalName === fv.fieldName);
const isNumeric = field ? isNumericFieldType(field.typeAsString) : false;
return (
// eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом
<div className={styles.conditionRow} key={index}>
<Select
options={fieldNameOptions}
value={fv.fieldName}
disabled={sharePointFields.isLoading}
placeholder={
sharePointFields.isLoading
? texts.resolve.conditionFieldLoading
: texts.resolve.conditionFieldPlaceholder
}
onChange={e => updateFieldValue(index, { fieldName: e.target.value })}
/>
<TextInput
type={isNumeric ? 'number' : 'text'}
value={fv.value}
placeholder={texts.resolve.conditionValuePlaceholder}
onChange={e => updateFieldValue(index, { value: e.target.value })}
/>
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => removeFieldValue(index)} />
</div>
);
})}
<Button variant="secondary" size="sm" icon="plus" onClick={addFieldValue}>
{texts.resolve.conditionAdd}
</Button>
</>
)}
</div>
<div className={styles.runButtonRow}>
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
@@ -137,7 +233,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
{result && (
<>
<Alert kind="ok">
{texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)}
{texts.resolve.summary(result.routeId, result.requiresControl)}
</Alert>
{unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>}
<DataTable
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Company, Department, RequestType, RouteType, TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts';
import { Company, Department, RequestType, TemplateRoute } from '../../models';
import { texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
@@ -21,22 +21,16 @@ export interface IRouteFormModalProps {
interface IFormState {
requestTypeId: string;
routeType: RouteType;
companyId: string;
departmentId: string;
byProject: boolean;
requiresControl: boolean;
}
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
.filter(v => v !== RouteType.None)
.map(v => ({ value: v, label: routeTypeLabels[v] }));
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
if (!editing) {
return {
requestTypeId: initialRequestTypeId,
routeType: RouteType.Sequential,
companyId: '',
departmentId: '',
byProject: false,
@@ -45,8 +39,7 @@ function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: s
}
return {
requestTypeId: String(editing.requestTypeId),
routeType: editing.routeType,
companyId: editing.companyId,
companyId: editing.companyId ?? '',
departmentId: editing.departmentId ?? '',
byProject: editing.byProject,
requiresControl: editing.requiresControl
@@ -80,24 +73,19 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
submit.fail(texts.routes.validationRequestType);
return;
}
if (!form.companyId.trim()) {
submit.fail(texts.routes.validationCompany);
return;
}
const companyId = form.companyId.trim() ? form.companyId.trim() : null;
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
const request = editing
? api.templateRoutes.update(editing.id, {
routeType: form.routeType,
companyId: form.companyId.trim(),
companyId,
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
})
: api.templateRoutes.create({
routeType: form.routeType,
requestTypeId: Number(form.requestTypeId),
companyId: form.companyId.trim(),
companyId,
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
@@ -129,18 +117,13 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
/>
</Field>
<Field label={texts.routes.routeTypeLabel} required full>
<Select
options={ROUTE_TYPE_OPTIONS}
value={form.routeType}
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
/>
</Field>
<Field label={texts.routes.companyLabel} required full hint={texts.routes.companyHint}>
<Field label={texts.routes.companyLabel} full hint={texts.routes.companyHint}>
<SearchableSelect
options={companies.map(c => ({ value: c.id, label: c.name }))}
value={form.companyId}
loading={dictionaryLoading}
allowClear
clearLabel={texts.routes.companyClear}
placeholder={texts.routes.companyPlaceholder}
searchPlaceholder={texts.routes.companySearchPlaceholder}
onChange={companyId =>
@@ -16,7 +16,7 @@ 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 { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
export interface IRouteStepFormModalProps {
@@ -123,6 +123,25 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
return options;
}, [sharePointFields.items, form.fieldName]);
// Тип столбца известен, только если он есть среди подгруженных полей списка —
// для значения, введённого до появления поля в списке (или после его удаления),
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
const operationOptions = isNumericField
? OPERATION_OPTIONS
: OPERATION_OPTIONS.filter(o => o.value === ComparisonOperator.Equal);
// Строковая колонка допускает только «равно» — если пользователь сменил
// field_name на нечисловую колонку, а раньше был выбран «больше»/«меньше», сбрасываем.
React.useEffect(() => {
if (!isNumericField && form.operation !== ComparisonOperator.Equal) {
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNumericField]);
const submitForm = (): void => {
const orderNo = Number(form.orderNo);
if (!orderNo || orderNo < 1) {
@@ -141,6 +160,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationCondition);
return;
}
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber);
return;
}
const payload = {
orderNo,
@@ -293,13 +316,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
</Field>
<Field label={texts.routeSteps.conditionOperationLabel} required>
<Select
options={OPERATION_OPTIONS}
options={operationOptions}
value={form.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
/>
</Field>
<Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
@@ -4,8 +4,8 @@ import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor';
import { routeStepStatusLabels, texts } from '../../texts';
import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -76,7 +76,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
/>
<PageHeader
icon="flag-check"
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])}
subtitle={texts.routeSteps.subtitle(requestTypeName)}
title={texts.routeSteps.title(route.id)}
actions={
<>
@@ -110,7 +110,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
key: 'status',
header: texts.routeSteps.columnStatus,
render: r => (
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
)
@@ -121,6 +121,11 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'condition',
header: texts.routeSteps.columnCondition,
render: r => describeStepCondition(r) ?? texts.common.empty
},
{
key: 'actions',
header: '',
@@ -3,8 +3,8 @@ import styles from './RoutesScreen.module.scss';
import { RouteFormModal } from './RouteFormModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteType, TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts';
import { TemplateRoute } from '../../models';
import { texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
@@ -92,12 +92,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
header: texts.routes.columnRequestType,
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
},
{
key: 'routeType',
header: texts.routes.columnRouteType,
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
},
{ key: 'company', header: texts.routes.columnCompany, render: r => companyName(r.companyId) },
{ key: 'company', header: texts.routes.columnCompany, render: r => (r.companyId ? companyName(r.companyId) : texts.common.empty) },
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
{
key: 'byProject',
@@ -1,5 +1,6 @@
import * as React from 'react';
import styles from './SearchableSelect.module.scss';
import { DropdownPortal } from './DropdownPortal';
import { Icon } from './Icon';
import { texts } from '../../texts';
@@ -12,6 +13,8 @@ export interface IAsyncSearchableSelectProps<T> {
value: string;
/** Название выбранного значения — показываем в триггере без похода на сервер. */
selectedLabel?: string;
/** Вторая строка триггера мельче и серым — например, url под названием сайта. */
selectedSublabel?: string;
onChange: (value: string, item: T | undefined) => void;
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
getValue: (item: T) => string;
@@ -29,6 +32,7 @@ export interface IAsyncSearchableSelectProps<T> {
export function AsyncSearchableSelect<T>({
value,
selectedLabel,
selectedSublabel,
onChange,
search,
getValue,
@@ -51,6 +55,7 @@ export function AsyncSearchableSelect<T>({
const [error, setError] = React.useState<string | undefined>();
const rootRef = React.useRef<HTMLDivElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const sentinelRef = React.useRef<HTMLDivElement>(null);
@@ -103,7 +108,10 @@ export function AsyncSearchableSelect<T>({
if (!open) return;
const onDocumentMouseDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
const target = e.target as Node;
if (rootRef.current?.contains(target)) return;
if (menuRef.current?.contains(target)) return;
setOpen(false);
};
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false);
@@ -161,14 +169,20 @@ export function AsyncSearchableSelect<T>({
onClick={toggle}
disabled={disabled}
>
{value && selectedSublabel ? (
<span className={styles.valueStack}>
<span className={styles.valueMain}>{selectedLabel ?? value}</span>
<span className={styles.valueSub}>{selectedSublabel}</span>
</span>
) : (
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
{value ? (selectedLabel ?? value) : placeholder}
</span>
)}
<Icon name="chevron-down" size="sm" className={styles.chevron} />
</button>
{open && (
<div className={styles.menu}>
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
<input
ref={searchRef}
className={styles.search}
@@ -210,8 +224,7 @@ export function AsyncSearchableSelect<T>({
</button>
</div>
)}
</div>
)}
</DropdownPortal>
</div>
);
}
@@ -0,0 +1,78 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
export interface IDropdownPortalProps {
/** Элемент, под которым (или над которым) рисуется меню. */
anchorRef: React.RefObject<HTMLElement>;
open: boolean;
className?: string;
/** Прокидывается наружу — нужен обработчику клика вне меню (портал живёт вне DOM-дерева root). */
menuRef?: React.Ref<HTMLDivElement>;
children: React.ReactNode;
}
interface IPosition {
top?: number;
bottom?: number;
left: number;
width: number;
}
const GAP = 6;
const MIN_SPACE_BELOW = 240;
/**
* Рендерит выпадающее меню в document.body с position: fixed по координатам
* anchor-элемента, а не как обычный absolute-потомок. Внутри модалок наши формы
* скроллятся через overflow-y: auto на .box (Modal.module.scss) — обычный
* position: absolute-дочерний элемент обрезается этим скроллом, если список не
* помещается. Портал избегает этого, т.к. живёт вне скроллящегося поддерева.
*/
export const DropdownPortal: React.FC<IDropdownPortalProps> = ({ anchorRef, open, className, menuRef, children }) => {
const [position, setPosition] = React.useState<IPosition | undefined>();
const updatePosition = React.useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const openUp = spaceBelow < MIN_SPACE_BELOW && rect.top > spaceBelow;
setPosition(
openUp
? { bottom: window.innerHeight - rect.top + GAP, left: rect.left, width: rect.width }
: { top: rect.bottom + GAP, left: rect.left, width: rect.width }
);
}, [anchorRef]);
React.useLayoutEffect(() => {
if (!open) return undefined;
updatePosition();
// capture: true — ловим скролл любого предка (в т.ч. .box модалки), не только window.
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [open, updatePosition]);
if (!open || !position) return null;
const style: React.CSSProperties = {
position: 'fixed',
left: position.left,
width: position.width,
top: position.top,
bottom: position.bottom
};
return ReactDOM.createPortal(
<div ref={menuRef} className={className} style={style}>
{children}
</div>,
document.body
);
};
@@ -29,21 +29,10 @@
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: $sh-2;
box-shadow: $sh-1;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 14px;
&::after {
content: '';
position: absolute;
right: -60px;
top: -60px;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(212,160,30,.14), transparent 65%);
}
}
.phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
@@ -52,9 +41,9 @@
width: 46px;
height: 46px;
border-radius: $r-md;
background: rgba(212,160,30,.14);
border: 1px solid rgba(212,160,30,.3);
color: $gold-300;
background: $gold-100;
border: 1px solid $gold-300;
color: $gold-600;
display: flex;
align-items: center;
justify-content: center;
@@ -63,7 +52,7 @@
.phSub {
font-size: 10.5px;
color: rgba(255,255,255,.55);
color: $gray-500;
text-transform: uppercase;
letter-spacing: 1.8px;
font-weight: 500;
@@ -73,7 +62,7 @@
font-family: $font-serif;
font-size: $fs-page-title;
font-weight: 600;
color: #fff;
color: $navy-900;
margin-top: 3px;
letter-spacing: .2px;
}
@@ -35,15 +35,39 @@
white-space: nowrap;
}
// Двухстрочный триггер — название сверху, мелкая серая подпись (url) снизу,
// как itemSub в списке. Отдельно от .value, у которого одна строка целиком
// участвует в ellipsis и не может параллельно нести подпись другим кеглем.
.valueStack {
display: flex;
flex-direction: column;
min-width: 0;
text-align: left;
}
.valueMain {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.valueSub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: $gray-400;
margin-top: 1px;
}
.placeholder { color: $gray-400; }
.chevron { color: $gray-400; flex-shrink: 0; }
.menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
// position/top/left/width задаются инлайн из DropdownPortal (position: fixed
// по координатам триггера) — портал рендерится в document.body, чтобы список
// не обрезался overflow-y: auto модалки (Modal.module.scss .box).
background: #fff;
border-radius: $r-md;
box-shadow: $sh-modal;
@@ -1,5 +1,6 @@
import * as React from 'react';
import styles from './SearchableSelect.module.scss';
import { DropdownPortal } from './DropdownPortal';
import { Icon } from './Icon';
import { texts } from '../../texts';
@@ -40,6 +41,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const rootRef = React.useRef<HTMLDivElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
@@ -68,9 +70,10 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
if (!open) return;
const onDocumentMouseDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
const target = e.target as Node;
if (rootRef.current?.contains(target)) return;
if (menuRef.current?.contains(target)) return;
setOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false);
@@ -113,8 +116,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
<Icon name="chevron-down" size="sm" className={styles.chevron} />
</button>
{open && (
<div className={styles.menu}>
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
<input
ref={searchRef}
className={styles.search}
@@ -147,8 +149,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
</button>
</div>
)}
</div>
)}
</DropdownPortal>
</div>
);
};
@@ -0,0 +1,39 @@
import * as React from 'react';
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Как React.useState, но значение переживает перезагрузку страницы — хранится
* в localStorage под key. Падение доступа к localStorage (приватный режим,
* квота) не ломает форму — просто не персистит.
*
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
* обновления в форму добавили новое поле, у пользователей со старой записью в
* localStorage оно не потеряется как undefined (что уронило бы рендер на
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
*/
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = React.useState<T>(() => {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return initial;
const parsed = JSON.parse(raw) as T;
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
} catch {
return initial;
}
});
React.useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// приватный режим / переполненная квота — не критично, просто не персистим
}
}, [key, value]);
return [value, setValue];
}
@@ -0,0 +1,36 @@
import * as React from 'react';
import { SharePointListsClient } from '../services/sharePointLists';
export interface SharePointSiteTitleState {
title: string | null;
isLoading: boolean;
}
/** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */
export function useSharePointSiteTitle(
client: SharePointListsClient | undefined,
siteUrl: string | undefined | null
): SharePointSiteTitleState {
const [title, setTitle] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
if (!client || !siteUrl?.trim()) {
setTitle(null);
setIsLoading(false);
return undefined;
}
let cancelled = false;
setIsLoading(true);
client.getSiteTitle(siteUrl.trim())
.then(result => { if (!cancelled) setTitle(result); })
.catch(() => { if (!cancelled) setTitle(null); })
.finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; };
}, [client, siteUrl]);
return { title, isLoading };
}
@@ -2,12 +2,6 @@
// Значения должны 1:1 соответствовать C#-стороне: они ходят по сети как строки.
// Человекочитаемые подписи к этим значениям живут не здесь, а в texts.ts.
export enum RouteType {
None = 'None',
Parallel = 'Parallel',
Sequential = 'Sequential'
}
export enum RouteStepStatus {
None = 'None',
Approval = 'Approval',
@@ -1,13 +1,20 @@
// DTO эндпоинта api/template-routes/resolve
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
import { RouteStepStatus, RouteType } from './enums';
import { RouteStepStatus } from './enums';
/** Значение поля списка SharePoint (field_name шага) для проверки условий шагов. */
export interface ResolveFieldValue {
fieldName: string;
value: string;
}
export interface ResolveTemplateRouteQuery {
requestTypeId: number;
userEmail?: string;
projectId?: string;
status: RouteStepStatus;
fieldValues?: ResolveFieldValue[];
}
export interface ResolvedRouteStep {
@@ -22,7 +29,6 @@ export interface ResolvedRouteStep {
export interface ResolveTemplateRouteResult {
routeId: number;
routeType: RouteType;
requiresControl: boolean;
steps: ResolvedRouteStep[];
}
@@ -1,39 +1,34 @@
// DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs).
import { RouteType } from './enums';
export interface TemplateRoute {
id: number;
routeType: RouteType;
requestTypeId: number;
departmentId: string | null;
companyId: string;
/** Опционально — маршрут без компании служит запасным вариантом при resolve. */
companyId: string | null;
byProject: boolean;
requiresControl: boolean;
}
export interface CreateTemplateRouteInput {
routeType: RouteType;
requestTypeId: number;
departmentId: string | null;
companyId: string;
companyId: string | null;
byProject: boolean;
requiresControl: boolean;
}
// Тип заявки у существующего маршрута не меняется — его нет в update-контракте.
export interface UpdateTemplateRouteInput {
routeType: RouteType;
departmentId: string | null;
companyId: string;
companyId: string | null;
byProject: boolean;
requiresControl: boolean;
}
export interface PatchTemplateRouteInput {
routeType?: RouteType;
departmentId?: string | null;
companyId?: string;
companyId?: string | null;
byProject?: boolean;
requiresControl?: boolean;
}
@@ -61,8 +61,10 @@ export class ApprovalRouteApi {
remove: (id: number): Promise<void> =>
this.http.delete(`/api/template-routes/${id}`),
// POST, а не GET: запрос несёт список условий (fieldValues) переменной длины —
// не ложится на плоскую query-строку (см. IHttpClient.QueryParams).
resolve: (query: ResolveTemplateRouteQuery): Promise<ResolveTemplateRouteResult> =>
this.http.get<ResolveTemplateRouteResult>('/api/template-routes/resolve', { ...query })
this.http.post<ResolveTemplateRouteResult>('/api/template-routes/resolve', query)
};
public readonly templateRouteSteps = {
@@ -2,6 +2,7 @@ import { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/fields';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
// Списки, из которых типы заявок берут list_name/field_name, живут на произвольном
@@ -9,6 +10,10 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
// См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт».
//
// Поиск сайтов (searchSites), наоборот, идёт через поисковый индекс SharePoint —
// он общий на весь тенант независимо от того, с какого сайта пришёл запрос,
// поэтому там клиент строится под текущий сайт веб-части (SPFx(context) без url).
export interface SharePointList {
id: string;
@@ -18,6 +23,31 @@ export interface SharePointList {
export interface SharePointListField {
internalName: string;
title: string;
/** SharePoint TypeAsString (Text, Number, Currency, DateTime, Choice, ...). */
typeAsString: string;
}
export interface SharePointSiteSuggestion {
title: string;
url: string;
}
export interface SharePointSiteSearchResult {
items: SharePointSiteSuggestion[];
hasMore: boolean;
}
// SharePoint TypeAsString для колонок с числовыми значениями — только для них
// имеют смысл операторы «больше»/«меньше» (см. RouteStepFormModal).
const NUMERIC_FIELD_TYPES = new Set(['Number', 'Currency']);
export function isNumericFieldType(typeAsString: string): boolean {
return NUMERIC_FIELD_TYPES.has(typeAsString);
}
/** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */
export function formatSiteLabel(title: string | null | undefined, url: string): string {
return title ? `${title} — ${url}` : url;
}
interface IListItem {
@@ -28,6 +58,12 @@ interface IListItem {
interface IFieldItem {
InternalName: string;
Title: string;
TypeAsString: string;
}
interface ISearchResultRow {
Title?: string;
Path?: string;
}
function clientFor(context: WebPartContext, siteUrl: string): SPFI {
@@ -35,8 +71,33 @@ function clientFor(context: WebPartContext, siteUrl: string): SPFI {
}
export class SharePointListsClient {
// Заголовок сайта по site_url бэкенд не хранит (только сам url) — кэшируем
// на клиенте, чтобы не дёргать сайт заново на каждый рендер строки таблицы
// или переоткрытие формы с тем же значением.
private readonly siteTitleCache = new Map<string, string | null>();
constructor(private readonly context: WebPartContext) {}
/** Подмешать уже известный заголовок сайта (например, из результата поиска) — без похода в сеть. */
public cacheSiteTitle(siteUrl: string, title: string): void {
this.siteTitleCache.set(siteUrl, title);
}
/** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */
public async getSiteTitle(siteUrl: string): Promise<string | null> {
const cached = this.siteTitleCache.get(siteUrl);
if (cached !== undefined) return cached;
try {
const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string };
this.siteTitleCache.set(siteUrl, web.Title);
return web.Title;
} catch {
this.siteTitleCache.set(siteUrl, null);
return null;
}
}
/** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */
public async listLists(siteUrl: string): Promise<SharePointList[]> {
const sp = clientFor(this.context, siteUrl);
@@ -48,14 +109,42 @@ export class SharePointListsClient {
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')
.select('InternalName', 'Title', 'TypeAsString')
.filter('Hidden eq false and ReadOnlyField eq false')
.orderBy('Title')() as IFieldItem[];
return items.map(x => ({ internalName: x.InternalName, title: x.Title }));
return items.map(x => ({ internalName: x.InternalName, title: x.Title, typeAsString: x.TypeAsString }));
}
/**
* Поиск сайтов (site collections) по всему тенанту через поисковый индекс
* SharePoint (contentclass:STS_Site) — не требует заранее знать site_url.
* Результаты обрезаны правами доступа текущего пользователя, как и любой
* SharePoint Search запрос.
*/
public async searchSites(query: string, page: number, pageSize = 20): Promise<SharePointSiteSearchResult> {
const sp = spfi().using(SPFx(this.context));
const escaped = query.trim().replace(/["*()]/g, '');
const queryText = escaped ? `contentclass:STS_Site ${escaped}*` : 'contentclass:STS_Site';
const result = await sp.search({
Querytext: queryText,
SelectProperties: ['Title', 'Path'],
RowLimit: pageSize,
StartRow: (page - 1) * pageSize,
TrimDuplicates: true
});
const rows = (result.PrimarySearchResults ?? []) as ISearchResultRow[];
const items = rows
.filter(r => Boolean(r.Path))
.map(r => ({ title: r.Title || r.Path as string, url: r.Path as string }));
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows };
}
}
+26 -18
View File
@@ -4,19 +4,13 @@
//
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus, RouteType } from './models';
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './models';
import { pluralRu } from './utils/plural';
// ---- Подписи enum'ов ----
// Record<enum, string> гарантирует, что при добавлении значения в enum
// компилятор потребует и подпись к нему.
export const routeTypeLabels: Record<RouteType, string> = {
[RouteType.None]: 'Не задан',
[RouteType.Parallel]: 'Параллельный',
[RouteType.Sequential]: 'Последовательный'
};
export const routeStepStatusLabels: Record<RouteStepStatus, string> = {
[RouteStepStatus.None]: 'Не задан',
[RouteStepStatus.Approval]: 'Согласование',
@@ -45,6 +39,13 @@ export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
[ComparisonOperator.Equal]: 'Равно (=)'
};
export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '',
[ComparisonOperator.GreaterThan]: '>',
[ComparisonOperator.LessThan]: '<',
[ComparisonOperator.Equal]: '='
};
export const texts = {
common: {
home: 'Главная',
@@ -62,8 +63,6 @@ export const texts = {
},
nav: {
/** Буква в золотом квадрате логотипа — не зависит от заголовка панели. */
markLetter: 'T',
title: 'Панель настройки маршрутов',
dashboard: 'Дашборд',
requestTypes: 'Типы заявок',
@@ -104,6 +103,8 @@ export const texts = {
columnName: 'Название',
columnPageUrl: 'Url страницы',
columnSection: 'Раздел',
columnListName: 'Список SharePoint',
columnSiteUrl: 'Сайт SharePoint',
createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки',
nameLabel: 'Название',
@@ -116,6 +117,7 @@ export const texts = {
siteUrlLabel: 'Сайт SharePoint',
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
siteUrlSearchPlaceholder: 'Поиск сайта по названию...',
listNameLabel: 'Список SharePoint',
listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.',
listNamePlaceholder: 'Сначала укажите сайт SharePoint',
@@ -139,7 +141,6 @@ export const texts = {
dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`,
emptyList: 'Маршрутов пока нет.',
columnRequestType: 'Тип заявки',
columnRouteType: 'Тип маршрута',
columnCompany: 'Компания',
columnDepartment: 'Отдел',
columnByProject: 'По проекту',
@@ -150,11 +151,11 @@ export const texts = {
editTitle: (routeId: number): string => `Маршрут #${routeId}`,
requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки',
routeTypeLabel: 'Тип маршрута',
companyLabel: 'Компания',
companyHint: 'Справочник 1С (api.tnsu.kz).',
companyPlaceholder: 'Выберите компанию',
companyHint: 'Необязательно — пусто означает запасной маршрут, если для компании инициатора своего маршрута нет.',
companyPlaceholder: 'Без компании (запасной маршрут)',
companySearchPlaceholder: 'Поиск по названию компании...',
companyClear: 'Без компании (запасной маршрут)',
departmentLabel: 'Отдел',
departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.',
departmentHintNoCompany: 'Сначала выберите компанию.',
@@ -166,7 +167,6 @@ export const texts = {
controlLabel: 'Контроль',
controlCheckbox: 'Требуется отдельный контроль исполнения',
validationRequestType: 'Выберите тип заявки.',
validationCompany: 'Компания обязательна (Guid из справочника api.tnsu.kz).',
deleteTitle: 'Удалить маршрут?',
deleteText: (routeId: number): string =>
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`
@@ -175,8 +175,7 @@ export const texts = {
routeSteps: {
breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`,
title: (routeId: number): string => `Маршрут #${routeId}`,
subtitle: (requestTypeName: string, routeTypeLabel: string): string =>
`${requestTypeName} · ${routeTypeLabel}`,
subtitle: (requestTypeName: string): string => requestTypeName,
back: 'К маршрутам',
add: 'Добавить шаг',
emptyList: 'Шагов пока нет — добавьте первый.',
@@ -184,6 +183,7 @@ export const texts = {
columnExecutor: 'Исполнитель',
columnStatus: 'Статус',
columnReassign: 'Переназначение',
columnCondition: 'Условие',
reassignAllowed: 'Разрешено',
executorRole: (roleLabel: string): string => `Роль: ${roleLabel}`,
executorHead: (headLabel: string): string => `Руководитель: ${headLabel}`,
@@ -222,6 +222,7 @@ export const texts = {
validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
},
@@ -245,11 +246,18 @@ export const texts = {
projectClear: 'Без проекта',
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
statusLabel: 'Статус',
conditionsLabel: 'Условия (значения полей заявки)',
conditionsDisabledHint: 'Недоступно: у выбранного типа заявки не указан список SharePoint (list_name).',
conditionFieldPlaceholder: 'Выберите столбец списка',
conditionFieldLoading: 'Загрузка столбцов списка...',
conditionValuePlaceholder: 'Значение для проверки',
conditionAdd: 'Добавить условие',
run: 'Проверить',
running: 'Проверка...',
validationRequestType: 'Выберите тип заявки.',
summary: (routeId: number, routeTypeLabel: string, requiresControl: boolean): string =>
`Маршрут #${routeId} · ${routeTypeLabel}${requiresControl ? ' · требуется контроль исполнения' : ''}`,
validationConditionField: 'В каждом условии заполните и поле, и значение.',
summary: (routeId: number, requiresControl: boolean): string =>
`Маршрут #${routeId}${requiresControl ? ' · требуется контроль исполнения' : ''}`,
unresolved: (count: number): string =>
`Исполнитель не определён: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} — причина указана в строке.`,
positionMissing: 'Должность не указана в карточке сотрудника.',
@@ -1,5 +1,5 @@
import { Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
import { headTypeLabels, projectRoleLabels, texts } from '../texts';
import { ComparisonOperator, Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } from '../texts';
import { findEmployeeByEmail } from './employee';
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
@@ -36,3 +36,9 @@ export function describeStepExecutor(step: TemplateRouteStep, employees: Employe
}
return { primary: texts.common.empty };
}
/** Колонка «Условие» в таблице шагов — undefined, если у шага условия нет. */
export function describeStepCondition(step: TemplateRouteStep): string | undefined {
if (!step.fieldName || !step.operation || step.operation === ComparisonOperator.None || !step.value) return undefined;
return `${step.fieldName} ${comparisonOperatorSymbols[step.operation]} ${step.value}`;
}