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")] [Produces("application/json")]
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase 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(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [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(); (await service.ResolveAsync(request, ct)).ToActionResult();
[HttpGet] [HttpGet]
@@ -12,12 +12,15 @@ public sealed record ResolvedRouteStepDto(
public sealed record ResolveTemplateRouteResultDto( public sealed record ResolveTemplateRouteResultDto(
int RouteId, int RouteId,
RouteType RouteType,
bool RequiresControl, bool RequiresControl,
IReadOnlyList<ResolvedRouteStepDto> Steps); IReadOnlyList<ResolvedRouteStepDto> Steps);
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
public sealed record ResolveFieldValueDto(string FieldName, string Value);
public sealed record ResolveTemplateRouteRequest( public sealed record ResolveTemplateRouteRequest(
int RequestTypeId, int RequestTypeId,
string? UserEmail, string? UserEmail,
Guid? ProjectId, Guid? ProjectId,
RouteStepStatus Status); RouteStepStatus Status,
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null);
@@ -1,33 +1,27 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record TemplateRouteDto( public sealed record TemplateRouteDto(
int Id, int Id,
RouteType RouteType,
int RequestTypeId, int RequestTypeId,
Guid? DepartmentId, Guid? DepartmentId,
Guid CompanyId, Guid? CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record CreateTemplateRouteRequest( public sealed record CreateTemplateRouteRequest(
RouteType RouteType,
int RequestTypeId, int RequestTypeId,
Guid? DepartmentId, Guid? DepartmentId,
Guid CompanyId, Guid? CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record UpdateTemplateRouteRequest( public sealed record UpdateTemplateRouteRequest(
RouteType RouteType,
Guid? DepartmentId, Guid? DepartmentId,
Guid CompanyId, Guid? CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record PatchTemplateRouteRequest( public sealed record PatchTemplateRouteRequest(
RouteType? RouteType,
Guid? DepartmentId, Guid? DepartmentId,
Guid? CompanyId, Guid? CompanyId,
bool? ByProject, bool? ByProject,
@@ -1,3 +1,4 @@
using System.Globalization;
using FluentResults; using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -24,7 +25,7 @@ public sealed class TemplateRouteService(
var items = await query var items = await query
.Select(x => new TemplateRouteDto( .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); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
@@ -52,7 +53,6 @@ public sealed class TemplateRouteService(
var entity = new TemplateRoute var entity = new TemplateRoute
{ {
RouteType = r.RouteType,
RequestTypeId = r.RequestTypeId, RequestTypeId = r.RequestTypeId,
DepartmentId = r.DepartmentId, DepartmentId = r.DepartmentId,
CompanyId = r.CompanyId, CompanyId = r.CompanyId,
@@ -76,7 +76,6 @@ public sealed class TemplateRouteService(
return Result.Fail<TemplateRouteDto>(new ConflictError( return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType;
entity.DepartmentId = r.DepartmentId; entity.DepartmentId = r.DepartmentId;
entity.CompanyId = r.CompanyId; entity.CompanyId = r.CompanyId;
entity.ByProject = r.ByProject; entity.ByProject = r.ByProject;
@@ -101,7 +100,6 @@ public sealed class TemplateRouteService(
return Result.Fail<TemplateRouteDto>(new ConflictError( return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType ?? entity.RouteType;
entity.DepartmentId = departmentId; entity.DepartmentId = departmentId;
entity.CompanyId = companyId; entity.CompanyId = companyId;
entity.ByProject = byProject; entity.ByProject = byProject;
@@ -142,22 +140,11 @@ public sealed class TemplateRouteService(
var byProject = request.ProjectId is not null; var byProject = request.ProjectId is not null;
var route = await db.TemplateRoutes.AsNoTracking() // Приоритет — маршрут с компанией инициатора; если для этой компании
.Include(x => x.Steps) // своего маршрута нет, берём запасной маршрут без указанной компании
.Where(x => // (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
x.RequestTypeId == request.RequestTypeId && var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
x.CompanyId == employee.CompanyId && ?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
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);
if (route is null) if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError( return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
@@ -168,8 +155,14 @@ public sealed class TemplateRouteService(
if (request.ProjectId is { } projectId) if (request.ProjectId is { } projectId)
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); 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 var matchingSteps = route.Steps
.Where(s => s.Status == request.Status) .Where(s => s.Status == request.Status)
.Where(s => MatchesCondition(s, fieldValues))
.OrderBy(s => s.OrderNo) .OrderBy(s => s.OrderNo)
.ToList(); .ToList();
@@ -179,11 +172,36 @@ public sealed class TemplateRouteService(
var collapsedSteps = CollapseByExecutorEmail(steps); 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( 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 => db.TemplateRoutes.AnyAsync(x =>
x.Id != excludeId && x.Id != excludeId &&
x.RequestTypeId == requestTypeId && x.RequestTypeId == requestTypeId &&
@@ -243,6 +261,37 @@ public sealed class TemplateRouteService(
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning); 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) private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
{ {
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -260,5 +309,5 @@ public sealed class TemplateRouteService(
} }
private static TemplateRouteDto ToDto(TemplateRoute e) => new( 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; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<CreateTemplateRouteRequest> // Компания и отдел маршрута — оба опциональны, поэтому CreateTemplateRouteRequest/
{ // UpdateTemplateRouteRequest/PatchTemplateRouteRequest не нуждаются в отдельных
public CreateTemplateRouteRequestValidator() // валидаторах: маршрут без компании служит запасным вариантом, когда для
{ // конкретной компании инициатора своего маршрута нет (см. ResolveAsync).
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("Компания обязательна."));
}
}
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest> public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
{ {
public ResolveTemplateRouteRequestValidator() public ResolveTemplateRouteRequestValidator()
{ {
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); 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; namespace Tnsu.ApprovalRoute.Domain.Entities;
public class TemplateRoute public class TemplateRoute
{ {
public int Id { get; set; } public int Id { get; set; }
public RouteType RouteType { get; set; } = RouteType.Sequential;
public int RequestTypeId { get; set; } public int RequestTypeId { get; set; }
public Guid? DepartmentId { get; set; } public Guid? DepartmentId { get; set; }
public Guid CompanyId { get; set; } public Guid? CompanyId { get; set; }
public bool ByProject { get; set; } public bool ByProject { get; set; }
public bool RequiresControl { 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 => modelBuilder.Entity<TemplateRoute>(e =>
{ {
e.HasKey(x => x.Id); 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.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(); 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 namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{ {
[DbContext(typeof(ApprovalRouteDbContext))] [DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260731073536_AddRequestTypeListNameSectionAndStepConditionFields")] [Migration("20260731111928_AddRequestTypeListNameSectionAndStepConditionFields")]
partial class AddRequestTypeListNameSectionAndStepConditionFields partial class AddRequestTypeListNameSectionAndStepConditionFields
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -81,7 +81,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("by_project"); .HasColumnName("by_project");
b.Property<Guid>("CompanyId") b.Property<Guid?>("CompanyId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("company_id"); .HasColumnName("company_id");
@@ -97,12 +97,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("requires_control"); .HasColumnName("requires_control");
b.Property<string>("RouteType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("route_type");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_routes"); .HasName("pk_template_routes");
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations; using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -10,6 +11,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) 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>( migrationBuilder.AddColumn<string>(
name: "field_name", name: "field_name",
table: "template_route_steps", table: "template_route_steps",
@@ -80,6 +93,24 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
migrationBuilder.DropColumn( migrationBuilder.DropColumn(
name: "site_url", name: "site_url",
table: "request_types"); 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") .HasColumnType("boolean")
.HasColumnName("by_project"); .HasColumnName("by_project");
b.Property<Guid>("CompanyId") b.Property<Guid?>("CompanyId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("company_id"); .HasColumnName("company_id");
@@ -94,12 +94,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("requires_control"); .HasColumnName("requires_control");
b.Property<string>("RouteType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("route_type");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_routes"); .HasName("pk_template_routes");
@@ -40,6 +40,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
private async Task<int> CreateRequestTypeAsync(string name) => private async Task<int> CreateRequestTypeAsync(string name) =>
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id; (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 возвращает сохранённые данные")] [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
{ {
@@ -48,7 +53,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Action // Action
var created = await _service.CreateAsync( 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); CancellationToken.None);
// Assert // Assert
@@ -65,7 +70,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{ {
// Action // Action
var result = await _service.CreateAsync( 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); CancellationToken.None);
// Assert // Assert
@@ -77,7 +82,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{ {
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Business trip"); 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); var first = await _service.CreateAsync(request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
@@ -94,18 +99,17 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Sick leave"); var requestTypeId = await CreateRequestTypeAsync("Sick leave");
var created = await _service.CreateAsync( 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); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), new UpdateTemplateRouteRequest(DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
updated.IsSuccess.ShouldBeTrue(); updated.IsSuccess.ShouldBeTrue();
updated.Value.RouteType.ShouldBe(RouteType.Parallel);
updated.Value.RequiresControl.ShouldBeTrue(); updated.Value.RequiresControl.ShouldBeTrue();
} }
@@ -115,18 +119,17 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Patch route request"); var requestTypeId = await CreateRequestTypeAsync("Patch route request");
var created = await _service.CreateAsync( 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); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
created.Value.Id, 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); CancellationToken.None);
// Assert // Assert
patched.IsSuccess.ShouldBeTrue(); patched.IsSuccess.ShouldBeTrue();
patched.Value.RouteType.ShouldBe(RouteType.Parallel);
patched.Value.RequiresControl.ShouldBeTrue(); patched.Value.RequiresControl.ShouldBeTrue();
patched.Value.CompanyId.ShouldBe(CompanyId); patched.Value.CompanyId.ShouldBe(CompanyId);
patched.Value.DepartmentId.ShouldBe(DepartmentId); patched.Value.DepartmentId.ShouldBe(DepartmentId);
@@ -138,7 +141,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Remote work"); var requestTypeId = await CreateRequestTypeAsync("Remote work");
var created = await _service.CreateAsync( 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); CancellationToken.None);
// Action // Action
@@ -157,7 +160,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Exact match request"); var requestTypeId = await CreateRequestTypeAsync("Exact match request");
var route = await _service.CreateAsync( 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); CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -198,7 +201,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var requestTypeId = (await requestTypeService.CreateAsync( var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates", null, null, "General"), CancellationToken.None)).Value.Id; new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates", null, null, "General"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
@@ -228,13 +231,123 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[1].OrderNo.ShouldBe(2); 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 = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{ {
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Project request"); var requestTypeId = await CreateRequestTypeAsync("Project request");
var route = await _service.CreateAsync( 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); CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -274,7 +387,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var requestTypeId = (await requestTypeService.CreateAsync( var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Position request", "/position-request", null, null, "General"), CancellationToken.None)).Value.Id; new CreateRequestTypeRequest("Position request", "/position-request", null, null, "General"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
@@ -313,7 +426,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var requestTypeId = (await requestTypeService.CreateAsync( var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Chief request", "/chief-request", null, null, "General"), CancellationToken.None)).Value.Id; new CreateRequestTypeRequest("Chief request", "/chief-request", null, null, "General"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
@@ -348,7 +461,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var requestTypeId = (await requestTypeService.CreateAsync( var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request", null, null, "General"), CancellationToken.None)).Value.Id; new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request", null, null, "General"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
@@ -373,7 +486,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Chief not found request"); var requestTypeId = await CreateRequestTypeAsync("Chief not found request");
var route = await _service.CreateAsync( 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); CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -401,7 +514,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Position not found request"); var requestTypeId = await CreateRequestTypeAsync("Position not found request");
var route = await _service.CreateAsync( 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); CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -429,7 +542,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Project role not found request"); var requestTypeId = await CreateRequestTypeAsync("Project role not found request");
var route = await _service.CreateAsync( 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); CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -457,7 +570,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
var route = await _service.CreateAsync( 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); CancellationToken.None);
// Action // Action
@@ -470,6 +583,48 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.RouteId.ShouldBe(route.Value.Id); 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 = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")] [Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")]
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult() 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; new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}", listName, siteUrl, "General"), CancellationToken.None)).Value.Id;
var route = await _routeService.CreateAsync( var route = await _routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), new CreateTemplateRouteRequest(requestTypeId, null, CompanyId, byProject, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
return route.Value.Id; return route.Value.Id;
+7 -8
View File
@@ -106,19 +106,18 @@ $sp-7: 28px; $sp-8: 32px;
} }
// ---- Готовые миксины ---- // ---- Готовые миксины ----
// Светлая карточка заголовка страницы — сознательно НЕ грд-header (тёмный,
// как TopNav): два одинаковых тёмно-синих блока подряд читаются как один
// тяжёлый слипшийся элемент. Золотая полоса сверху — единственный акцент,
// вместо заливки всей карточки.
@mixin page-header { @mixin page-header {
background: $grad-header; background: #fff;
border: 1px solid $gray-200;
border-top: 3px solid $gold-500;
border-radius: $r-lg; border-radius: $r-lg;
padding: 26px 30px; padding: 26px 30px;
position: relative; position: relative;
overflow: hidden; 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 { @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' && ( {screen === 'resolve' && (
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} /> <ResolveScreen api={api} dictionary={dictionary} sharePoint={sharePoint} currentUserEmail={currentUserEmail} />
)} )}
</div> </div>
</div> </div>
@@ -23,20 +23,6 @@
flex-shrink: 0; 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 { .brandText {
color: #fff; color: #fff;
font-weight: 600; font-weight: 600;
@@ -72,7 +72,6 @@ export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
return ( return (
<nav className={styles.topnav}> <nav className={styles.topnav}>
<div className={styles.brand}> <div className={styles.brand}>
<div className={styles.mark}>{texts.nav.markLetter}</div>
<div className={styles.brandText}>{texts.nav.title}</div> <div className={styles.brandText}>{texts.nav.title}</div>
</div> </div>
<div className={styles.tabs} ref={tabsRef}> <div className={styles.tabs} ref={tabsRef}>
@@ -1,15 +1,17 @@
import * as React from 'react'; import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { SharePointListsClient } from '../../services/sharePointLists'; import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
import { RequestType } from '../../models'; import { RequestType } from '../../models';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useSharePointLists } from '../../hooks/useSharePointLists'; import { useSharePointLists } from '../../hooks/useSharePointLists';
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { import {
ActionsCell, ActionsCell,
Alert, Alert,
AsyncSearchableSelect,
Breadcrumbs, Breadcrumbs,
Button, Button,
ConfirmDialog, ConfirmDialog,
@@ -39,6 +41,12 @@ interface IFormState {
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' }; 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 }) => { export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
const list = useRequestTypes(api); 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 [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); 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); 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: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl }, { key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section }, { key: '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', key: 'actions',
header: '', header: '',
@@ -167,10 +182,22 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
/> />
</Field> </Field>
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}> <Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
<TextInput <AsyncSearchableSelect<SharePointSiteSuggestion>
value={form.siteUrl} 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} 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>
<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 { .runButtonRow {
margin-top: 18px; margin-top: 18px;
} }
@@ -2,11 +2,14 @@ import * as React from 'react';
import styles from './ResolveScreen.module.scss'; import styles from './ResolveScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { Project, RouteStepStatus } from '../../models'; import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { Project, ResolveFieldValue, RouteStepStatus } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useResolveRoute } from '../../hooks/useResolveRoute'; import { useResolveRoute } from '../../hooks/useResolveRoute';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
import { import {
Alert, Alert,
@@ -17,30 +20,58 @@ import {
DataTable, DataTable,
Field, Field,
FormGrid, FormGrid,
IconButton,
PageHeader, PageHeader,
Panel, Panel,
PrimaryCell, PrimaryCell,
SearchableSelect, SearchableSelect,
Select Select,
TextInput
} from '../ui'; } from '../ui';
export interface IResolveScreenProps { export interface IResolveScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient; dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
currentUserEmail?: string; currentUserEmail?: string;
} }
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
.map(v => ({ value: v, label: routeStepStatusLabels[v] })); .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 requestTypes = useRequestTypes(api);
const [requestTypeId, setRequestTypeId] = React.useState(''); const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
const [userEmail, setUserEmail] = React.useState(''); const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
const [projectId, setProjectId] = React.useState('');
const [projectLabel, setProjectLabel] = React.useState(''); const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval); 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 resolved = useResolveRoute(api);
const employees = usePortalEmployees(dictionary); const employees = usePortalEmployees(dictionary);
@@ -55,16 +86,35 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
[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 => { const run = (): void => {
if (!requestTypeId) { if (!requestTypeId) {
resolved.fail(texts.resolve.validationRequestType); resolved.fail(texts.resolve.validationRequestType);
return; 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({ resolved.resolve({
requestTypeId: Number(requestTypeId), requestTypeId: Number(requestTypeId),
userEmail: userEmail.trim() || undefined, userEmail: userEmail.trim() || undefined,
projectId: projectId.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 }))} options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.resolve.requestTypePlaceholder} placeholder={texts.resolve.requestTypePlaceholder}
value={requestTypeId} value={requestTypeId}
onChange={e => setRequestTypeId(e.target.value)} onChange={e => setForm({ ...form, requestTypeId: e.target.value, fieldValues: [] })}
/> />
</Field> </Field>
<Field <Field
@@ -99,7 +149,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
placeholder={texts.resolve.userEmailPlaceholder} placeholder={texts.resolve.userEmailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder} searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty} emptyMessage={texts.select.employeesEmpty}
onChange={setUserEmail} onChange={userEmail => setForm({ ...form, userEmail })}
/> />
</Field> </Field>
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}> <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} getValue={p => p.oid}
getLabel={p => p.name} getLabel={p => p.name}
getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined} getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined}
onChange={(nextId, item) => { onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })}
setProjectId(nextId);
setProjectLabel(item ? item.name : '');
}}
/> />
</Field> </Field>
<Field label={texts.resolve.statusLabel} required> <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> </Field>
</FormGrid> </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}> <div className={styles.runButtonRow}>
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}> <Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
{resolved.isBusy ? texts.resolve.running : texts.resolve.run} {resolved.isBusy ? texts.resolve.running : texts.resolve.run}
@@ -137,7 +233,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
{result && ( {result && (
<> <>
<Alert kind="ok"> <Alert kind="ok">
{texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)} {texts.resolve.summary(result.routeId, result.requiresControl)}
</Alert> </Alert>
{unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>} {unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>}
<DataTable <DataTable
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { Company, Department, RequestType, RouteType, TemplateRoute } from '../../models'; import { Company, Department, RequestType, TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts'; import { texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui'; import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
@@ -21,22 +21,16 @@ export interface IRouteFormModalProps {
interface IFormState { interface IFormState {
requestTypeId: string; requestTypeId: string;
routeType: RouteType;
companyId: string; companyId: string;
departmentId: string; departmentId: string;
byProject: boolean; byProject: boolean;
requiresControl: 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 { function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
if (!editing) { if (!editing) {
return { return {
requestTypeId: initialRequestTypeId, requestTypeId: initialRequestTypeId,
routeType: RouteType.Sequential,
companyId: '', companyId: '',
departmentId: '', departmentId: '',
byProject: false, byProject: false,
@@ -45,8 +39,7 @@ function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: s
} }
return { return {
requestTypeId: String(editing.requestTypeId), requestTypeId: String(editing.requestTypeId),
routeType: editing.routeType, companyId: editing.companyId ?? '',
companyId: editing.companyId,
departmentId: editing.departmentId ?? '', departmentId: editing.departmentId ?? '',
byProject: editing.byProject, byProject: editing.byProject,
requiresControl: editing.requiresControl requiresControl: editing.requiresControl
@@ -80,24 +73,19 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
submit.fail(texts.routes.validationRequestType); submit.fail(texts.routes.validationRequestType);
return; 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 departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
const request = editing const request = editing
? api.templateRoutes.update(editing.id, { ? api.templateRoutes.update(editing.id, {
routeType: form.routeType, companyId,
companyId: form.companyId.trim(),
departmentId, departmentId,
byProject: form.byProject, byProject: form.byProject,
requiresControl: form.requiresControl requiresControl: form.requiresControl
}) })
: api.templateRoutes.create({ : api.templateRoutes.create({
routeType: form.routeType,
requestTypeId: Number(form.requestTypeId), requestTypeId: Number(form.requestTypeId),
companyId: form.companyId.trim(), companyId,
departmentId, departmentId,
byProject: form.byProject, byProject: form.byProject,
requiresControl: form.requiresControl requiresControl: form.requiresControl
@@ -129,18 +117,13 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onChange={e => setForm({ ...form, requestTypeId: e.target.value })} onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
/> />
</Field> </Field>
<Field label={texts.routes.routeTypeLabel} required full> <Field label={texts.routes.companyLabel} full hint={texts.routes.companyHint}>
<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}>
<SearchableSelect <SearchableSelect
options={companies.map(c => ({ value: c.id, label: c.name }))} options={companies.map(c => ({ value: c.id, label: c.name }))}
value={form.companyId} value={form.companyId}
loading={dictionaryLoading} loading={dictionaryLoading}
allowClear
clearLabel={texts.routes.companyClear}
placeholder={texts.routes.companyPlaceholder} placeholder={texts.routes.companyPlaceholder}
searchPlaceholder={texts.routes.companySearchPlaceholder} searchPlaceholder={texts.routes.companySearchPlaceholder}
onChange={companyId => onChange={companyId =>
@@ -16,7 +16,7 @@ import { toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields'; import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; 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'; import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
export interface IRouteStepFormModalProps { export interface IRouteStepFormModalProps {
@@ -123,6 +123,25 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
return options; return options;
}, [sharePointFields.items, form.fieldName]); }, [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 submitForm = (): void => {
const orderNo = Number(form.orderNo); const orderNo = Number(form.orderNo);
if (!orderNo || orderNo < 1) { if (!orderNo || orderNo < 1) {
@@ -141,6 +160,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationCondition); submit.fail(texts.routeSteps.validationCondition);
return; return;
} }
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber);
return;
}
const payload = { const payload = {
orderNo, orderNo,
@@ -293,13 +316,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
</Field> </Field>
<Field label={texts.routeSteps.conditionOperationLabel} required> <Field label={texts.routeSteps.conditionOperationLabel} required>
<Select <Select
options={OPERATION_OPTIONS} options={operationOptions}
value={form.operation} value={form.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })} onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
/> />
</Field> </Field>
<Field label={texts.routeSteps.conditionValueLabel} required> <Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput <TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value} value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })} onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder} placeholder={texts.routeSteps.conditionValuePlaceholder}
@@ -4,8 +4,8 @@ import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { SharePointListsClient } from '../../services/sharePointLists'; import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { routeStepStatusLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor'; import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -76,7 +76,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
/> />
<PageHeader <PageHeader
icon="flag-check" icon="flag-check"
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])} subtitle={texts.routeSteps.subtitle(requestTypeName)}
title={texts.routeSteps.title(route.id)} title={texts.routeSteps.title(route.id)}
actions={ actions={
<> <>
@@ -110,7 +110,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
key: 'status', key: 'status',
header: texts.routeSteps.columnStatus, header: texts.routeSteps.columnStatus,
render: r => ( render: r => (
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}> <Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]} {routeStepStatusLabels[r.status]}
</Badge> </Badge>
) )
@@ -121,6 +121,11 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
align: 'center', align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>) 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', key: 'actions',
header: '', header: '',
@@ -3,8 +3,8 @@ import styles from './RoutesScreen.module.scss';
import { RouteFormModal } from './RouteFormModal'; import { RouteFormModal } from './RouteFormModal';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteType, TemplateRoute } from '../../models'; import { TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts'; import { texts } from '../../texts';
import { shortGuid } from '../../utils/guid'; import { shortGuid } from '../../utils/guid';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes'; import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
@@ -92,12 +92,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
header: texts.routes.columnRequestType, header: texts.routes.columnRequestType,
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
}, },
{ { key: 'company', header: texts.routes.columnCompany, render: r => (r.companyId ? companyName(r.companyId) : texts.common.empty) },
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: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) }, { key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
{ {
key: 'byProject', key: 'byProject',
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './SearchableSelect.module.scss'; import styles from './SearchableSelect.module.scss';
import { DropdownPortal } from './DropdownPortal';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { texts } from '../../texts'; import { texts } from '../../texts';
@@ -12,6 +13,8 @@ export interface IAsyncSearchableSelectProps<T> {
value: string; value: string;
/** Название выбранного значения — показываем в триггере без похода на сервер. */ /** Название выбранного значения — показываем в триггере без похода на сервер. */
selectedLabel?: string; selectedLabel?: string;
/** Вторая строка триггера мельче и серым — например, url под названием сайта. */
selectedSublabel?: string;
onChange: (value: string, item: T | undefined) => void; onChange: (value: string, item: T | undefined) => void;
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>; search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
getValue: (item: T) => string; getValue: (item: T) => string;
@@ -29,6 +32,7 @@ export interface IAsyncSearchableSelectProps<T> {
export function AsyncSearchableSelect<T>({ export function AsyncSearchableSelect<T>({
value, value,
selectedLabel, selectedLabel,
selectedSublabel,
onChange, onChange,
search, search,
getValue, getValue,
@@ -51,6 +55,7 @@ export function AsyncSearchableSelect<T>({
const [error, setError] = React.useState<string | undefined>(); const [error, setError] = React.useState<string | undefined>();
const rootRef = React.useRef<HTMLDivElement>(null); const rootRef = React.useRef<HTMLDivElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null); const searchRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null);
const sentinelRef = React.useRef<HTMLDivElement>(null); const sentinelRef = React.useRef<HTMLDivElement>(null);
@@ -103,7 +108,10 @@ export function AsyncSearchableSelect<T>({
if (!open) return; if (!open) return;
const onDocumentMouseDown = (e: MouseEvent): void => { 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 => { const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false); if (e.key === 'Escape') setOpen(false);
@@ -161,14 +169,20 @@ export function AsyncSearchableSelect<T>({
onClick={toggle} onClick={toggle}
disabled={disabled} 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}`}> <span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
{value ? (selectedLabel ?? value) : placeholder} {value ? (selectedLabel ?? value) : placeholder}
</span> </span>
)}
<Icon name="chevron-down" size="sm" className={styles.chevron} /> <Icon name="chevron-down" size="sm" className={styles.chevron} />
</button> </button>
{open && ( <DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
<div className={styles.menu}>
<input <input
ref={searchRef} ref={searchRef}
className={styles.search} className={styles.search}
@@ -210,8 +224,7 @@ export function AsyncSearchableSelect<T>({
</button> </button>
</div> </div>
)} )}
</div> </DropdownPortal>
)}
</div> </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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
box-shadow: $sh-2; box-shadow: $sh-1;
margin-bottom: 24px; margin-bottom: 24px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 14px; 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; } .phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
@@ -52,9 +41,9 @@
width: 46px; width: 46px;
height: 46px; height: 46px;
border-radius: $r-md; border-radius: $r-md;
background: rgba(212,160,30,.14); background: $gold-100;
border: 1px solid rgba(212,160,30,.3); border: 1px solid $gold-300;
color: $gold-300; color: $gold-600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -63,7 +52,7 @@
.phSub { .phSub {
font-size: 10.5px; font-size: 10.5px;
color: rgba(255,255,255,.55); color: $gray-500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1.8px; letter-spacing: 1.8px;
font-weight: 500; font-weight: 500;
@@ -73,7 +62,7 @@
font-family: $font-serif; font-family: $font-serif;
font-size: $fs-page-title; font-size: $fs-page-title;
font-weight: 600; font-weight: 600;
color: #fff; color: $navy-900;
margin-top: 3px; margin-top: 3px;
letter-spacing: .2px; letter-spacing: .2px;
} }
@@ -35,15 +35,39 @@
white-space: nowrap; 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; } .placeholder { color: $gray-400; }
.chevron { color: $gray-400; flex-shrink: 0; } .chevron { color: $gray-400; flex-shrink: 0; }
.menu { .menu {
position: absolute; // position/top/left/width задаются инлайн из DropdownPortal (position: fixed
top: calc(100% + 6px); // по координатам триггера) — портал рендерится в document.body, чтобы список
left: 0; // не обрезался overflow-y: auto модалки (Modal.module.scss .box).
right: 0;
background: #fff; background: #fff;
border-radius: $r-md; border-radius: $r-md;
box-shadow: $sh-modal; box-shadow: $sh-modal;
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './SearchableSelect.module.scss'; import styles from './SearchableSelect.module.scss';
import { DropdownPortal } from './DropdownPortal';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { texts } from '../../texts'; import { texts } from '../../texts';
@@ -40,6 +41,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const rootRef = React.useRef<HTMLDivElement>(null); const rootRef = React.useRef<HTMLDivElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null); const searchRef = React.useRef<HTMLInputElement>(null);
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С // Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
@@ -68,9 +70,10 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
if (!open) return; if (!open) return;
const onDocumentMouseDown = (e: MouseEvent): void => { 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); setOpen(false);
}
}; };
const onKeyDown = (e: KeyboardEvent): void => { const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false); 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} /> <Icon name="chevron-down" size="sm" className={styles.chevron} />
</button> </button>
{open && ( <DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
<div className={styles.menu}>
<input <input
ref={searchRef} ref={searchRef}
className={styles.search} className={styles.search}
@@ -147,8 +149,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
</button> </button>
</div> </div>
)} )}
</div> </DropdownPortal>
)}
</div> </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#-стороне: они ходят по сети как строки. // Значения должны 1:1 соответствовать C#-стороне: они ходят по сети как строки.
// Человекочитаемые подписи к этим значениям живут не здесь, а в texts.ts. // Человекочитаемые подписи к этим значениям живут не здесь, а в texts.ts.
export enum RouteType {
None = 'None',
Parallel = 'Parallel',
Sequential = 'Sequential'
}
export enum RouteStepStatus { export enum RouteStepStatus {
None = 'None', None = 'None',
Approval = 'Approval', Approval = 'Approval',
@@ -1,13 +1,20 @@
// DTO эндпоинта api/template-routes/resolve // DTO эндпоинта api/template-routes/resolve
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs). // (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 { export interface ResolveTemplateRouteQuery {
requestTypeId: number; requestTypeId: number;
userEmail?: string; userEmail?: string;
projectId?: string; projectId?: string;
status: RouteStepStatus; status: RouteStepStatus;
fieldValues?: ResolveFieldValue[];
} }
export interface ResolvedRouteStep { export interface ResolvedRouteStep {
@@ -22,7 +29,6 @@ export interface ResolvedRouteStep {
export interface ResolveTemplateRouteResult { export interface ResolveTemplateRouteResult {
routeId: number; routeId: number;
routeType: RouteType;
requiresControl: boolean; requiresControl: boolean;
steps: ResolvedRouteStep[]; steps: ResolvedRouteStep[];
} }
@@ -1,39 +1,34 @@
// DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs). // DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs).
import { RouteType } from './enums';
export interface TemplateRoute { export interface TemplateRoute {
id: number; id: number;
routeType: RouteType;
requestTypeId: number; requestTypeId: number;
departmentId: string | null; departmentId: string | null;
companyId: string; /** Опционально — маршрут без компании служит запасным вариантом при resolve. */
companyId: string | null;
byProject: boolean; byProject: boolean;
requiresControl: boolean; requiresControl: boolean;
} }
export interface CreateTemplateRouteInput { export interface CreateTemplateRouteInput {
routeType: RouteType;
requestTypeId: number; requestTypeId: number;
departmentId: string | null; departmentId: string | null;
companyId: string; companyId: string | null;
byProject: boolean; byProject: boolean;
requiresControl: boolean; requiresControl: boolean;
} }
// Тип заявки у существующего маршрута не меняется — его нет в update-контракте. // Тип заявки у существующего маршрута не меняется — его нет в update-контракте.
export interface UpdateTemplateRouteInput { export interface UpdateTemplateRouteInput {
routeType: RouteType;
departmentId: string | null; departmentId: string | null;
companyId: string; companyId: string | null;
byProject: boolean; byProject: boolean;
requiresControl: boolean; requiresControl: boolean;
} }
export interface PatchTemplateRouteInput { export interface PatchTemplateRouteInput {
routeType?: RouteType;
departmentId?: string | null; departmentId?: string | null;
companyId?: string; companyId?: string | null;
byProject?: boolean; byProject?: boolean;
requiresControl?: boolean; requiresControl?: boolean;
} }
@@ -61,8 +61,10 @@ export class ApprovalRouteApi {
remove: (id: number): Promise<void> => remove: (id: number): Promise<void> =>
this.http.delete(`/api/template-routes/${id}`), this.http.delete(`/api/template-routes/${id}`),
// POST, а не GET: запрос несёт список условий (fieldValues) переменной длины —
// не ложится на плоскую query-строку (см. IHttpClient.QueryParams).
resolve: (query: ResolveTemplateRouteQuery): Promise<ResolveTemplateRouteResult> => 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 = { public readonly templateRouteSteps = {
@@ -2,6 +2,7 @@ import { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs'; import '@pnp/sp/webs';
import '@pnp/sp/lists'; import '@pnp/sp/lists';
import '@pnp/sp/fields'; import '@pnp/sp/fields';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
// Списки, из которых типы заявок берут list_name/field_name, живут на произвольном // Списки, из которых типы заявок берут list_name/field_name, живут на произвольном
@@ -9,6 +10,10 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url // размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт. // (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
// См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт». // См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт».
//
// Поиск сайтов (searchSites), наоборот, идёт через поисковый индекс SharePoint —
// он общий на весь тенант независимо от того, с какого сайта пришёл запрос,
// поэтому там клиент строится под текущий сайт веб-части (SPFx(context) без url).
export interface SharePointList { export interface SharePointList {
id: string; id: string;
@@ -18,6 +23,31 @@ export interface SharePointList {
export interface SharePointListField { export interface SharePointListField {
internalName: string; internalName: string;
title: 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 { interface IListItem {
@@ -28,6 +58,12 @@ interface IListItem {
interface IFieldItem { interface IFieldItem {
InternalName: string; InternalName: string;
Title: string; Title: string;
TypeAsString: string;
}
interface ISearchResultRow {
Title?: string;
Path?: string;
} }
function clientFor(context: WebPartContext, siteUrl: string): SPFI { function clientFor(context: WebPartContext, siteUrl: string): SPFI {
@@ -35,8 +71,33 @@ function clientFor(context: WebPartContext, siteUrl: string): SPFI {
} }
export class SharePointListsClient { export class SharePointListsClient {
// Заголовок сайта по site_url бэкенд не хранит (только сам url) — кэшируем
// на клиенте, чтобы не дёргать сайт заново на каждый рендер строки таблицы
// или переоткрытие формы с тем же значением.
private readonly siteTitleCache = new Map<string, string | null>();
constructor(private readonly context: WebPartContext) {} 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) на указанном сайте. */ /** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */
public async listLists(siteUrl: string): Promise<SharePointList[]> { public async listLists(siteUrl: string): Promise<SharePointList[]> {
const sp = clientFor(this.context, siteUrl); const sp = clientFor(this.context, siteUrl);
@@ -48,14 +109,42 @@ export class SharePointListsClient {
return items.map(x => ({ id: x.Id, title: x.Title })); return items.map(x => ({ id: x.Id, title: x.Title }));
} }
/** Заголовки столбцов (внутреннее + отображаемое имя) списка — исключая служебные поля. */ /** Заголовки столбцов (внутреннее + отображаемое имя + тип) списка — исключая служебные поля. */
public async listFields(siteUrl: string, listTitle: string): Promise<SharePointListField[]> { public async listFields(siteUrl: string, listTitle: string): Promise<SharePointListField[]> {
const sp = clientFor(this.context, siteUrl); const sp = clientFor(this.context, siteUrl);
const items = await sp.web.lists.getByTitle(listTitle).fields const items = await sp.web.lists.getByTitle(listTitle).fields
.select('InternalName', 'Title') .select('InternalName', 'Title', 'TypeAsString')
.filter('Hidden eq false and ReadOnlyField eq false') .filter('Hidden eq false and ReadOnlyField eq false')
.orderBy('Title')() as IFieldItem[]; .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'; import { pluralRu } from './utils/plural';
// ---- Подписи enum'ов ---- // ---- Подписи enum'ов ----
// Record<enum, string> гарантирует, что при добавлении значения в enum // Record<enum, string> гарантирует, что при добавлении значения в enum
// компилятор потребует и подпись к нему. // компилятор потребует и подпись к нему.
export const routeTypeLabels: Record<RouteType, string> = {
[RouteType.None]: 'Не задан',
[RouteType.Parallel]: 'Параллельный',
[RouteType.Sequential]: 'Последовательный'
};
export const routeStepStatusLabels: Record<RouteStepStatus, string> = { export const routeStepStatusLabels: Record<RouteStepStatus, string> = {
[RouteStepStatus.None]: 'Не задан', [RouteStepStatus.None]: 'Не задан',
[RouteStepStatus.Approval]: 'Согласование', [RouteStepStatus.Approval]: 'Согласование',
@@ -45,6 +39,13 @@ export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
[ComparisonOperator.Equal]: 'Равно (=)' [ComparisonOperator.Equal]: 'Равно (=)'
}; };
export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '',
[ComparisonOperator.GreaterThan]: '>',
[ComparisonOperator.LessThan]: '<',
[ComparisonOperator.Equal]: '='
};
export const texts = { export const texts = {
common: { common: {
home: 'Главная', home: 'Главная',
@@ -62,8 +63,6 @@ export const texts = {
}, },
nav: { nav: {
/** Буква в золотом квадрате логотипа — не зависит от заголовка панели. */
markLetter: 'T',
title: 'Панель настройки маршрутов', title: 'Панель настройки маршрутов',
dashboard: 'Дашборд', dashboard: 'Дашборд',
requestTypes: 'Типы заявок', requestTypes: 'Типы заявок',
@@ -104,6 +103,8 @@ export const texts = {
columnName: 'Название', columnName: 'Название',
columnPageUrl: 'Url страницы', columnPageUrl: 'Url страницы',
columnSection: 'Раздел', columnSection: 'Раздел',
columnListName: 'Список SharePoint',
columnSiteUrl: 'Сайт SharePoint',
createTitle: 'Новый тип заявки', createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки', editTitle: 'Редактировать тип заявки',
nameLabel: 'Название', nameLabel: 'Название',
@@ -116,6 +117,7 @@ export const texts = {
siteUrlLabel: 'Сайт SharePoint', siteUrlLabel: 'Сайт SharePoint',
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.', siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm', siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
siteUrlSearchPlaceholder: 'Поиск сайта по названию...',
listNameLabel: 'Список SharePoint', listNameLabel: 'Список SharePoint',
listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.', listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.',
listNamePlaceholder: 'Сначала укажите сайт SharePoint', listNamePlaceholder: 'Сначала укажите сайт SharePoint',
@@ -139,7 +141,6 @@ export const texts = {
dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`, dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`,
emptyList: 'Маршрутов пока нет.', emptyList: 'Маршрутов пока нет.',
columnRequestType: 'Тип заявки', columnRequestType: 'Тип заявки',
columnRouteType: 'Тип маршрута',
columnCompany: 'Компания', columnCompany: 'Компания',
columnDepartment: 'Отдел', columnDepartment: 'Отдел',
columnByProject: 'По проекту', columnByProject: 'По проекту',
@@ -150,11 +151,11 @@ export const texts = {
editTitle: (routeId: number): string => `Маршрут #${routeId}`, editTitle: (routeId: number): string => `Маршрут #${routeId}`,
requestTypeLabel: 'Тип заявки', requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки', requestTypePlaceholder: 'Выберите тип заявки',
routeTypeLabel: 'Тип маршрута',
companyLabel: 'Компания', companyLabel: 'Компания',
companyHint: 'Справочник 1С (api.tnsu.kz).', companyHint: 'Необязательно — пусто означает запасной маршрут, если для компании инициатора своего маршрута нет.',
companyPlaceholder: 'Выберите компанию', companyPlaceholder: 'Без компании (запасной маршрут)',
companySearchPlaceholder: 'Поиск по названию компании...', companySearchPlaceholder: 'Поиск по названию компании...',
companyClear: 'Без компании (запасной маршрут)',
departmentLabel: 'Отдел', departmentLabel: 'Отдел',
departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.', departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.',
departmentHintNoCompany: 'Сначала выберите компанию.', departmentHintNoCompany: 'Сначала выберите компанию.',
@@ -166,7 +167,6 @@ export const texts = {
controlLabel: 'Контроль', controlLabel: 'Контроль',
controlCheckbox: 'Требуется отдельный контроль исполнения', controlCheckbox: 'Требуется отдельный контроль исполнения',
validationRequestType: 'Выберите тип заявки.', validationRequestType: 'Выберите тип заявки.',
validationCompany: 'Компания обязательна (Guid из справочника api.tnsu.kz).',
deleteTitle: 'Удалить маршрут?', deleteTitle: 'Удалить маршрут?',
deleteText: (routeId: number): string => deleteText: (routeId: number): string =>
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.` `Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`
@@ -175,8 +175,7 @@ export const texts = {
routeSteps: { routeSteps: {
breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`, breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`,
title: (routeId: number): string => `Маршрут #${routeId}`, title: (routeId: number): string => `Маршрут #${routeId}`,
subtitle: (requestTypeName: string, routeTypeLabel: string): string => subtitle: (requestTypeName: string): string => requestTypeName,
`${requestTypeName} · ${routeTypeLabel}`,
back: 'К маршрутам', back: 'К маршрутам',
add: 'Добавить шаг', add: 'Добавить шаг',
emptyList: 'Шагов пока нет — добавьте первый.', emptyList: 'Шагов пока нет — добавьте первый.',
@@ -184,6 +183,7 @@ export const texts = {
columnExecutor: 'Исполнитель', columnExecutor: 'Исполнитель',
columnStatus: 'Статус', columnStatus: 'Статус',
columnReassign: 'Переназначение', columnReassign: 'Переназначение',
columnCondition: 'Условие',
reassignAllowed: 'Разрешено', reassignAllowed: 'Разрешено',
executorRole: (roleLabel: string): string => `Роль: ${roleLabel}`, executorRole: (roleLabel: string): string => `Роль: ${roleLabel}`,
executorHead: (headLabel: string): string => `Руководитель: ${headLabel}`, executorHead: (headLabel: string): string => `Руководитель: ${headLabel}`,
@@ -222,6 +222,7 @@ export const texts = {
validationExecutor: 'Выберите сотрудника-исполнителя.', validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.', validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.', validationCondition: 'Заполните поле списка, оператор и значение условия.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
deleteTitle: 'Удалить шаг?', deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
}, },
@@ -245,11 +246,18 @@ export const texts = {
projectClear: 'Без проекта', projectClear: 'Без проекта',
projectDictionaryLoading: 'Справочник проектов ещё загружается.', projectDictionaryLoading: 'Справочник проектов ещё загружается.',
statusLabel: 'Статус', statusLabel: 'Статус',
conditionsLabel: 'Условия (значения полей заявки)',
conditionsDisabledHint: 'Недоступно: у выбранного типа заявки не указан список SharePoint (list_name).',
conditionFieldPlaceholder: 'Выберите столбец списка',
conditionFieldLoading: 'Загрузка столбцов списка...',
conditionValuePlaceholder: 'Значение для проверки',
conditionAdd: 'Добавить условие',
run: 'Проверить', run: 'Проверить',
running: 'Проверка...', running: 'Проверка...',
validationRequestType: 'Выберите тип заявки.', validationRequestType: 'Выберите тип заявки.',
summary: (routeId: number, routeTypeLabel: string, requiresControl: boolean): string => validationConditionField: 'В каждом условии заполните и поле, и значение.',
`Маршрут #${routeId} · ${routeTypeLabel}${requiresControl ? ' · требуется контроль исполнения' : ''}`, summary: (routeId: number, requiresControl: boolean): string =>
`Маршрут #${routeId}${requiresControl ? ' · требуется контроль исполнения' : ''}`,
unresolved: (count: number): string => unresolved: (count: number): string =>
`Исполнитель не определён: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} — причина указана в строке.`, `Исполнитель не определён: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} — причина указана в строке.`,
positionMissing: 'Должность не указана в карточке сотрудника.', positionMissing: 'Должность не указана в карточке сотрудника.',
@@ -1,5 +1,5 @@
import { Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models'; import { ComparisonOperator, Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
import { headTypeLabels, projectRoleLabels, texts } from '../texts'; import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } from '../texts';
import { findEmployeeByEmail } from './employee'; import { findEmployeeByEmail } from './employee';
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */ /** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
@@ -36,3 +36,9 @@ export function describeStepExecutor(step: TemplateRouteStep, employees: Employe
} }
return { primary: texts.common.empty }; 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}`;
}