Новый виды условных шагов (#6)
Tests / test (push) Successful in 4m19s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
2026-07-31 12:15:15 +00:00
parent 3e91ecdd96
commit 13db1388a3
60 changed files with 2151 additions and 406 deletions
+98
View File
@@ -0,0 +1,98 @@
name: Build & Deploy to SharePoint Online
on:
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]
@@ -6,9 +6,10 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"Postgres": "Host=db-master.tnsu.kz;Database=tnsu_approval_route;Username=dbuser;Password=p}{6{Pa]{JQwP1H" "Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
}, },
"Entra": { "Entra": {
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0" "Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
}, },
@@ -1,9 +1,9 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypes; namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed record RequestTypeDto(int Id, string Name, string PageUrl); public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListName, string? SiteUrl, string Section);
public sealed record CreateRequestTypeRequest(string Name, string PageUrl); public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section);
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl); public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section);
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl); public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListName, string? SiteUrl, string? Section);
@@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
{ {
var items = await db.RequestTypes.AsNoTracking() var items = await db.RequestTypes.AsNoTracking()
.OrderBy(x => x.Name) .OrderBy(x => x.Name)
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl)) .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListName, x.SiteUrl, x.Section))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items); return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
@@ -29,7 +29,18 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
{ {
var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl }; if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
var entity = new RequestType
{
Name = request.Name,
PageUrl = request.PageUrl,
ListName = request.ListName,
SiteUrl = request.SiteUrl,
Section = request.Section
};
db.RequestTypes.Add(entity); db.RequestTypes.Add(entity);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -42,8 +53,15 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
if (entity is null) if (entity is null)
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id)); return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
entity.Name = request.Name; entity.Name = request.Name;
entity.PageUrl = request.PageUrl; entity.PageUrl = request.PageUrl;
entity.ListName = request.ListName;
entity.SiteUrl = request.SiteUrl;
entity.Section = request.Section;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
@@ -55,8 +73,18 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
if (entity is null) if (entity is null)
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id)); return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
var listName = request.ListName ?? entity.ListName;
var siteUrl = request.SiteUrl ?? entity.SiteUrl;
if (!RequestTypeFields.IsListReferenceValid(listName, siteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
entity.Name = request.Name ?? entity.Name; entity.Name = request.Name ?? entity.Name;
entity.PageUrl = request.PageUrl ?? entity.PageUrl; entity.PageUrl = request.PageUrl ?? entity.PageUrl;
entity.ListName = listName;
entity.SiteUrl = siteUrl;
entity.Section = request.Section ?? entity.Section;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
@@ -74,5 +102,5 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
return Result.Ok(); return Result.Ok();
} }
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl); private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListName, e.SiteUrl, e.Section);
} }
@@ -8,6 +8,10 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator<Create
{ {
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
RuleFor(x => x)
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl))
.WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
} }
} }
@@ -17,6 +21,10 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<Update
{ {
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
RuleFor(x => x)
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl))
.WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
} }
} }
@@ -28,5 +36,13 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRe
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым.")); RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым."));
When(x => x.PageUrl is not null, () => When(x => x.PageUrl is not null, () =>
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым.")); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
When(x => x.Section is not null, () =>
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым."));
} }
} }
internal static class RequestTypeFields
{
public static bool IsListReferenceValid(string? listName, string? siteUrl) =>
string.IsNullOrWhiteSpace(listName) == string.IsNullOrWhiteSpace(siteUrl);
}
@@ -12,6 +12,9 @@ public sealed record TemplateRouteStepDto(
RouteStepStatus Status, RouteStepStatus Status,
int RouteId, int RouteId,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value,
string? Warning = null); string? Warning = null);
public sealed record CreateTemplateRouteStepRequest( public sealed record CreateTemplateRouteStepRequest(
@@ -21,7 +24,10 @@ public sealed record CreateTemplateRouteStepRequest(
ProjectRole? ProjectRole, ProjectRole? ProjectRole,
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
public sealed record UpdateTemplateRouteStepRequest( public sealed record UpdateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
@@ -30,7 +36,10 @@ public sealed record UpdateTemplateRouteStepRequest(
ProjectRole? ProjectRole, ProjectRole? ProjectRole,
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
public sealed record PatchTemplateRouteStepRequest( public sealed record PatchTemplateRouteStepRequest(
int? OrderNo, int? OrderNo,
@@ -39,4 +48,7 @@ public sealed record PatchTemplateRouteStepRequest(
ProjectRole? ProjectRole, ProjectRole? ProjectRole,
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus? Status, RouteStepStatus? Status,
bool? AllowReassignToOtherApprovers); bool? AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
@@ -15,7 +15,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
.Where(x => x.RouteId == routeId) .Where(x => x.RouteId == routeId)
.OrderBy(x => x.OrderNo) .OrderBy(x => x.OrderNo)
.Select(x => new TemplateRouteStepDto( .Select(x => new TemplateRouteStepDto(
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers)) x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
x.FieldName, x.Operation, x.Value))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
@@ -33,7 +34,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken)
{ {
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
@@ -41,6 +42,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken); route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
if (duplicate) if (duplicate)
@@ -56,7 +65,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
ProjectRole = r.ProjectRole, ProjectRole = r.ProjectRole,
HeadType = r.HeadType, HeadType = r.HeadType,
Status = r.Status, Status = r.Status,
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
FieldName = r.FieldName,
Operation = r.Operation,
Value = r.Value
}; };
db.TemplateRouteSteps.Add(entity); db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -67,7 +79,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
public async Task<Result<TemplateRouteStepDto>> UpdateAsync( public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken)
{ {
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
@@ -79,6 +91,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken); route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
if (duplicate) if (duplicate)
@@ -92,6 +112,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.HeadType = r.HeadType; entity.HeadType = r.HeadType;
entity.Status = r.Status; entity.Status = r.Status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
entity.FieldName = r.FieldName;
entity.Operation = r.Operation;
entity.Value = r.Value;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
@@ -100,7 +123,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
public async Task<Result<TemplateRouteStepDto>> PatchAsync( public async Task<Result<TemplateRouteStepDto>> PatchAsync(
int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken) int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken)
{ {
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
@@ -113,6 +136,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
var projectRole = r.ProjectRole ?? entity.ProjectRole; var projectRole = r.ProjectRole ?? entity.ProjectRole;
var headType = r.HeadType ?? entity.HeadType; var headType = r.HeadType ?? entity.HeadType;
var status = r.Status ?? entity.Status; var status = r.Status ?? entity.Status;
var fieldName = r.FieldName ?? entity.FieldName;
var operation = r.Operation ?? entity.Operation;
var value = r.Value ?? entity.Value;
if (status == RouteStepStatus.None) if (status == RouteStepStatus.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution.")); return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
@@ -125,6 +151,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresListName(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken); route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
if (duplicate) if (duplicate)
@@ -138,6 +172,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.HeadType = headType; entity.HeadType = headType;
entity.Status = status; entity.Status = status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
entity.FieldName = fieldName;
entity.Operation = operation;
entity.Value = value;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
@@ -155,6 +192,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Ok(); return Result.Ok();
} }
private static bool RequiresListName(string? fieldName, ComparisonOperator? operation, string? value) =>
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
private Task<bool> HasDuplicateAsync( private Task<bool> HasDuplicateAsync(
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) => RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) =>
@@ -184,5 +224,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
} }
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers); e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
e.FieldName, e.Operation, e.Value);
} }
@@ -11,6 +11,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
} }
} }
@@ -22,6 +25,9 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
} }
} }
@@ -32,4 +38,15 @@ internal static class TemplateRouteStepFields
(string.IsNullOrWhiteSpace(position) ? 0 : 1) + (string.IsNullOrWhiteSpace(position) ? 0 : 1) +
(projectRole is null or ProjectRole.None ? 0 : 1) + (projectRole is null or ProjectRole.None ? 0 : 1) +
(headType is null or HeadType.None ? 0 : 1); (headType is null or HeadType.None ? 0 : 1);
public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) =>
(string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) +
(operation is null or ComparisonOperator.None ? 0 : 1) +
(string.IsNullOrWhiteSpace(value) ? 0 : 1);
public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value)
{
var count = CountConditionFilled(fieldName, operation, value);
return count == 0 || count == 3;
}
} }
@@ -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("Значение поля обязательно.");
});
} }
} }
@@ -5,6 +5,9 @@ public class RequestType
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string PageUrl { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty;
public string? ListName { get; set; }
public string? SiteUrl { get; set; }
public string Section { get; set; } = string.Empty;
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>(); public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
} }
@@ -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; }
@@ -16,5 +16,9 @@ public class TemplateRouteStep
public int RouteId { get; set; } public int RouteId { get; set; }
public bool AllowReassignToOtherApprovers { get; set; } public bool AllowReassignToOtherApprovers { get; set; }
public string? FieldName { get; set; }
public ComparisonOperator? Operation { get; set; }
public string? Value { get; set; }
public TemplateRoute? Route { get; set; } public TemplateRoute? Route { get; set; }
} }
@@ -0,0 +1,9 @@
namespace Tnsu.ApprovalRoute.Domain.Enums;
public enum ComparisonOperator
{
None,
GreaterThan,
LessThan,
Equal
}
@@ -1,8 +0,0 @@
namespace Tnsu.ApprovalRoute.Domain.Enums;
public enum RouteType
{
None,
Parallel,
Sequential
}
@@ -16,12 +16,14 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.Property(x => x.Name).HasMaxLength(256).IsRequired(); e.Property(x => x.Name).HasMaxLength(256).IsRequired();
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
e.Property(x => x.ListName).HasMaxLength(256);
e.Property(x => x.SiteUrl).HasMaxLength(512);
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
}); });
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();
}); });
@@ -34,6 +36,9 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32); e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32); e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
e.Property(x => x.FieldName).HasMaxLength(128);
e.Property(x => x.Operation).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Value).HasMaxLength(256);
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
e.HasIndex(x => new { x.RouteId, x.OrderNo }); e.HasIndex(x => new { x.RouteId, x.OrderNo });
}); });
@@ -0,0 +1,217 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260731111928_AddRequestTypeListNameSectionAndStepConditionFields")]
partial class AddRequestTypeListNameSectionAndStepConditionFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ListName")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("list_name");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.HasKey("Id")
.HasName("pk_request_types");
b.ToTable("request_types", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("FieldName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<string>("Operation")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<string>("Value")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("template_route_steps", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Navigation("Routes");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Navigation("Steps");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,116 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRequestTypeListNameSectionAndStepConditionFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "route_type",
table: "template_routes");
migrationBuilder.AlterColumn<Guid>(
name: "company_id",
table: "template_routes",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>(
name: "field_name",
table: "template_route_steps",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "operation",
table: "template_route_steps",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "value",
table: "template_route_steps",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "list_name",
table: "request_types",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "section",
table: "request_types",
type: "character varying(256)",
maxLength: 256,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "site_url",
table: "request_types",
type: "character varying(512)",
maxLength: 512,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "field_name",
table: "template_route_steps");
migrationBuilder.DropColumn(
name: "operation",
table: "template_route_steps");
migrationBuilder.DropColumn(
name: "value",
table: "template_route_steps");
migrationBuilder.DropColumn(
name: "list_name",
table: "request_types");
migrationBuilder.DropColumn(
name: "section",
table: "request_types");
migrationBuilder.DropColumn(
name: "site_url",
table: "request_types");
migrationBuilder.AlterColumn<Guid>(
name: "company_id",
table: "template_routes",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "route_type",
table: "template_routes",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
}
}
}
@@ -31,6 +31,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ListName")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("list_name");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -43,6 +48,17 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(512)") .HasColumnType("character varying(512)")
.HasColumnName("page_url"); .HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_request_types"); .HasName("pk_request_types");
@@ -62,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");
@@ -78,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");
@@ -112,11 +122,21 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("executor_email"); .HasColumnName("executor_email");
b.Property<string>("FieldName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("HeadType") b.Property<string>("HeadType")
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("head_type"); .HasColumnName("head_type");
b.Property<string>("Operation")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo") b.Property<int>("OrderNo")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("order_no"); .HasColumnName("order_no");
@@ -141,6 +161,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("status"); .HasColumnName("status");
b.Property<string>("Value")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_route_steps"); .HasName("pk_template_route_steps");
@@ -13,7 +13,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity()
{ {
// Action // Action
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None); var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation", null, null, "General"), CancellationToken.None);
// Assert // Assert
created.IsSuccess.ShouldBeTrue(); created.IsSuccess.ShouldBeTrue();
@@ -43,11 +43,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task UpdateAsync_ChangesFields() public async Task UpdateAsync_ChangesFields()
{ {
// Arrange // Arrange
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None); var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old", null, null, "General"), CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None); created.Value.Id, new UpdateRequestTypeRequest("New name", "/new", null, null, "General"), CancellationToken.None);
// Assert // Assert
updated.IsSuccess.ShouldBeTrue(); updated.IsSuccess.ShouldBeTrue();
@@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
{ {
// Action // Action
var result = await _service.UpdateAsync( var result = await _service.UpdateAsync(
999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); 999999, new UpdateRequestTypeRequest("X", "/x", null, null, "General"), CancellationToken.None);
// Assert // Assert
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
@@ -70,11 +70,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task PatchAsync_UpdatesOnlyProvidedFields() public async Task PatchAsync_UpdatesOnlyProvidedFields()
{ {
// Arrange // Arrange
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None); var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original", null, null, "General"), CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None); created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None);
// Assert // Assert
patched.IsSuccess.ShouldBeTrue(); patched.IsSuccess.ShouldBeTrue();
@@ -87,7 +87,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
{ {
// Action // Action
var result = await _service.PatchAsync( var result = await _service.PatchAsync(
999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None); 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None);
// Assert // Assert
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
@@ -97,7 +97,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task DeleteAsync_RemovesEntity() public async Task DeleteAsync_RemovesEntity()
{ {
// Arrange // Arrange
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None); var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp", null, null, "General"), CancellationToken.None);
// Action // Action
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
@@ -109,11 +109,37 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
fetched.IsFailed.ShouldBeTrue(); fetched.IsFailed.ShouldBeTrue();
} }
[Fact(DisplayName = "Создание типа заявки с list_name без site_url возвращает неуспешный результат")]
public async Task CreateAsync_ListNameWithoutSiteUrl_ReturnsFailedResult()
{
// Action
var result = await _service.CreateAsync(
new CreateRequestTypeRequest("Missing site url", "/missing-site-url", "Requests", null, "General"), CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Создание типа заявки с list_name и site_url успешно")]
public async Task CreateAsync_ListNameWithSiteUrl_ReturnsCreatedEntity()
{
// Action
var created = await _service.CreateAsync(
new CreateRequestTypeRequest(
"With list reference", "/with-list-reference", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.ListName.ShouldBe("Requests");
created.Value.SiteUrl.ShouldBe("https://tnsukz.sharepoint.com/sites/hrm");
}
[Fact(DisplayName = "Список типов заявок включает ранее созданный тип")] [Fact(DisplayName = "Список типов заявок включает ранее созданный тип")]
public async Task ListAsync_IncludesCreatedEntity() public async Task ListAsync_IncludesCreatedEntity()
{ {
// Arrange // Arrange
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None); var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed", null, null, "General"), CancellationToken.None);
// Action // Action
var list = await _service.ListAsync(CancellationToken.None); var list = await _service.ListAsync(CancellationToken.None);
@@ -38,7 +38,12 @@ 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}"), 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,13 +160,13 @@ 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>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -196,22 +199,22 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), 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(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -228,19 +231,129 @@ 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>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -272,13 +385,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Position request", "/position-request"), 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,
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -311,13 +424,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Chief request", "/chief-request"), 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,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -346,13 +459,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), 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,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -373,13 +486,13 @@ 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>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -401,13 +514,13 @@ 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>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -429,13 +542,13 @@ 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>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -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()
{ {
@@ -24,13 +24,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
_requestTypeService = services.GetRequiredService<RequestTypeService>(); _requestTypeService = services.GetRequiredService<RequestTypeService>();
} }
private async Task<int> CreateRouteAsync(string requestTypeName, bool byProject) private async Task<int> CreateRouteAsync(string requestTypeName, bool byProject, string? listName = null)
{ {
var siteUrl = listName is null ? null : "https://example.sharepoint.com/sites/test";
var requestTypeId = (await _requestTypeService.CreateAsync( var requestTypeId = (await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), 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;
@@ -42,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
999999, 999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -58,7 +59,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -70,14 +71,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
{ {
// Arrange // Arrange
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false); var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null);
var first = await _service.CreateAsync(routeId, request, CancellationToken.None); var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var duplicate = await _service.CreateAsync( var duplicate = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true), new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -93,7 +94,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -111,7 +112,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -131,7 +132,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -149,7 +150,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -164,14 +165,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update step", byProject: false); var routeId = await CreateRouteAsync("Route to update step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true), new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -188,14 +189,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step", byProject: false); var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null), new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -212,14 +213,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false); var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -233,14 +234,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false); var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -254,14 +255,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false); var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -269,6 +270,78 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
patched.Value.ExecutorEmail.ShouldBe("a@b.kz"); patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
} }
[Fact(DisplayName = "Создание шага с частично заполненными полями условия возвращает неуспешный результат")]
public async Task CreateAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route with partial condition", byProject: false, listName: "Requests");
// Action
var result = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", null, null),
CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Создание шага с условием на типе заявки без list_name возвращает неуспешный результат")]
public async Task CreateAsync_ConditionFieldsWithoutListName_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route without list name", byProject: false);
// Action
var result = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Создание шага с условием на типе заявки со list_name успешно")]
public async Task CreateAsync_ConditionFieldsWithListName_ReturnsCreatedStep()
{
// Arrange
var routeId = await CreateRouteAsync("Route with list name", byProject: false, listName: "Requests");
// Action
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.FieldName.ShouldBe("Amount");
created.Value.Operation.ShouldBe(ComparisonOperator.GreaterThan);
created.Value.Value.ShouldBe("1000");
}
[Fact(DisplayName = "Патч, после которого поля условия заполнены частично, возвращает неуспешный результат")]
public async Task PatchAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
// Action
var patched = await _service.PatchAsync(
routeId,
created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, FieldName: "Amount", Operation: null, Value: null),
CancellationToken.None);
// Assert
patched.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")] [Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
public async Task DeleteAsync_RemovesStep() public async Task DeleteAsync_RemovesStep()
{ {
@@ -276,7 +349,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to delete step", byProject: false); var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
+71
View File
@@ -11,10 +11,12 @@
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-component-base": "1.22.1",
"@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-core-library": "1.22.1",
"@microsoft/sp-http": "1.22.1",
"@microsoft/sp-lodash-subset": "1.22.1", "@microsoft/sp-lodash-subset": "1.22.1",
"@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -5051,6 +5053,75 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@pnp/core": {
"version": "4.20.0",
"resolved": "https://registry.npmjs.org/@pnp/core/-/core-4.20.0.tgz",
"integrity": "sha512-Pq421YYRxAEF3PEKSnhIpz1M5yqmehdmidn+0ImYKixI9V2UQgoWUohcb7bA2DiHXcM8HJl2nF/62PUmBUVAaQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.12.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/patrick-rodgers/"
}
},
"node_modules/@pnp/core/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@pnp/queryable": {
"version": "4.20.0",
"resolved": "https://registry.npmjs.org/@pnp/queryable/-/queryable-4.20.0.tgz",
"integrity": "sha512-JfnNrwPUYMP76gBx6rlVM4BGfsA+bU8bVYTY+RyHRPT4S0SIX2w6witvWHKTC0Ara9/JGGnbCI3LyQJ4457nGg==",
"license": "MIT",
"dependencies": {
"@pnp/core": "4.20.0",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.12.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/patrick-rodgers/"
}
},
"node_modules/@pnp/queryable/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@pnp/sp": {
"version": "4.20.0",
"resolved": "https://registry.npmjs.org/@pnp/sp/-/sp-4.20.0.tgz",
"integrity": "sha512-vz+45igqipK9ZJj/jzRMpK6OqI2Szi1Z48J9QtLCBeZVxT0IwKMT/HMkZca03cQL7IDRPSjq27DYcXQRYeRw3w==",
"license": "MIT",
"dependencies": {
"@pnp/core": "4.20.0",
"@pnp/queryable": "4.20.0",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.12.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/patrick-rodgers/"
}
},
"node_modules/@pnp/sp/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@rushstack/debug-certificate-manager": { "node_modules/@rushstack/debug-certificate-manager": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz", "resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz",
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@pnp/sp": "^4.20.0",
"@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-component-base": "1.22.1",
"@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-core-library": "1.22.1",
"@microsoft/sp-http": "1.22.1", "@microsoft/sp-http": "1.22.1",
+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

@@ -5,6 +5,7 @@ import { AdminApp } from './app/AdminApp';
import { ApprovalRouteApi } from '../services/api'; import { ApprovalRouteApi } from '../services/api';
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient'; import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary'; import { PortalDictionaryClient } from '../services/portalDictionary';
import { SharePointListsClient } from '../services/sharePointLists';
import { texts } from '../texts'; import { texts } from '../texts';
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts'; const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
@@ -30,6 +31,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>(); const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>(); const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
const [error, setError] = React.useState<string | undefined>(); const [error, setError] = React.useState<string | undefined>();
const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]);
React.useEffect(() => { React.useEffect(() => {
ensureFontsLoaded(); ensureFontsLoaded();
@@ -80,6 +82,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
<AdminApp <AdminApp
api={api} api={api}
dictionary={dictionary} dictionary={dictionary}
sharePoint={sharePoint}
currentUserEmail={props.context.pageContext.user.email} currentUserEmail={props.context.pageContext.user.email}
/> />
)} )}
@@ -4,7 +4,8 @@ import { TopNav } from './TopNav';
import { ScreenKey } from './types'; import { ScreenKey } from './types';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { TemplateRoute } from '../../models'; import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, TemplateRoute } from '../../models';
import { DashboardScreen } from '../screens/DashboardScreen'; import { DashboardScreen } from '../screens/DashboardScreen';
import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen';
import { RoutesScreen } from '../screens/RoutesScreen'; import { RoutesScreen } from '../screens/RoutesScreen';
@@ -15,13 +16,14 @@ export interface IAdminAppProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient; dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
currentUserEmail?: string; currentUserEmail?: string;
} }
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => { export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
const [screen, setScreen] = React.useState<ScreenKey>('dashboard'); const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>(); const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState(''); const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
const navigate = (key: ScreenKey): void => { const navigate = (key: ScreenKey): void => {
setScreen(key); setScreen(key);
@@ -29,9 +31,10 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
const openSteps = (route: TemplateRoute): void => { const openSteps = (route: TemplateRoute): void => {
setSelectedRoute(route); setSelectedRoute(route);
setSelectedRequestType(undefined);
api.requestTypes.get(route.requestTypeId) api.requestTypes.get(route.requestTypeId)
.then(rt => setSelectedRequestTypeName(rt.name)) .then(setSelectedRequestType)
.catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`)); .catch(() => setSelectedRequestType(undefined));
setScreen('routeSteps'); setScreen('routeSteps');
}; };
@@ -40,19 +43,20 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
<TopNav active={screen} onSelect={navigate} /> <TopNav active={screen} onSelect={navigate} />
<div className={styles.content}> <div className={styles.content}>
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />} {screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
{screen === 'requestTypes' && <RequestTypesScreen api={api} />} {screen === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />} {screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
{screen === 'routeSteps' && selectedRoute && ( {screen === 'routeSteps' && selectedRoute && (
<RouteStepsScreen <RouteStepsScreen
api={api} api={api}
route={selectedRoute} route={selectedRoute}
requestTypeName={selectedRequestTypeName} requestType={selectedRequestType}
dictionary={dictionary} dictionary={dictionary}
sharePoint={sharePoint}
onBack={() => setScreen('routes')} onBack={() => setScreen('routes')}
/> />
)} )}
{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,13 +1,17 @@
import * as React from 'react'; import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
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 { 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,
@@ -18,27 +22,44 @@ import {
IconButton, IconButton,
PageHeader, PageHeader,
PrimaryCell, PrimaryCell,
Select,
TextInput TextInput
} from '../ui'; } from '../ui';
export interface IRequestTypesScreenProps { export interface IRequestTypesScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
sharePoint: SharePointListsClient;
} }
interface IFormState { interface IFormState {
name: string; name: string;
pageUrl: string; pageUrl: string;
section: string;
siteUrl: string;
listName: string;
} }
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' }; const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' };
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => { /** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
const { title } = useSharePointSiteTitle(client, siteUrl);
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
};
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
const list = useRequestTypes(api); const list = useRequestTypes(api);
const [editing, setEditing] = React.useState<RequestType | undefined>(); const [editing, setEditing] = React.useState<RequestType | undefined>();
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM); const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
const 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);
@@ -50,16 +71,35 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
const openEdit = (item: RequestType): void => { const openEdit = (item: RequestType): void => {
setEditing(item); setEditing(item);
setForm({ name: item.name, pageUrl: item.pageUrl }); setForm({
name: item.name,
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listName: item.listName ?? ''
});
setShowForm(true); setShowForm(true);
}; };
const submitForm = (): void => { const submitForm = (): void => {
if (!form.name.trim() || !form.pageUrl.trim()) { if (!form.name.trim() || !form.pageUrl.trim() || !form.section.trim()) {
submit.fail(texts.requestTypes.validationRequired); submit.fail(texts.requestTypes.validationRequired);
return; return;
} }
const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() }; const siteUrl = form.siteUrl.trim();
const listName = form.listName.trim();
if (Boolean(siteUrl) !== Boolean(listName)) {
submit.fail(texts.requestTypes.validationListReference);
return;
}
const input = {
name: form.name.trim(),
pageUrl: form.pageUrl.trim(),
section: form.section.trim(),
siteUrl: siteUrl || null,
listName: listName || null
};
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input); const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
submit.run(() => submit.run(() =>
@@ -70,6 +110,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
); );
}; };
const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title }));
return ( return (
<div> <div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} /> <Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
@@ -87,6 +129,9 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
columns={[ columns={[
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> }, { key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl }, { key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
{ key: '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: '',
@@ -129,6 +174,53 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
placeholder={texts.requestTypes.pageUrlPlaceholder} placeholder={texts.requestTypes.pageUrlPlaceholder}
/> />
</Field> </Field>
<Field label={texts.requestTypes.sectionLabel} required full>
<TextInput
value={form.section}
onChange={e => setForm({ ...form, section: e.target.value })}
placeholder={texts.requestTypes.sectionPlaceholder}
/>
</Field>
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
<AsyncSearchableSelect<SharePointSiteSuggestion>
value={form.siteUrl}
selectedLabel={siteTitle.title ?? form.siteUrl}
selectedSublabel={siteTitle.title ? form.siteUrl : undefined}
allowClear
clearLabel={texts.select.notSelected}
placeholder={texts.requestTypes.siteUrlPlaceholder}
searchPlaceholder={texts.requestTypes.siteUrlSearchPlaceholder}
search={searchSites}
getValue={s => s.url}
getLabel={s => s.title}
getSublabel={s => s.url}
onChange={(siteUrl, item) => {
if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title);
setForm({ ...form, siteUrl, listName: '' });
}}
/>
</Field>
<Field
label={texts.requestTypes.listNameLabel}
full
hint={sharePointLists.error ? texts.requestTypes.listNameError(sharePointLists.error) : texts.requestTypes.listNameHint}
>
<Select
options={listNameOptions}
value={form.listName}
disabled={!form.siteUrl.trim() || sharePointLists.isLoading}
placeholder={
!form.siteUrl.trim()
? texts.requestTypes.listNamePlaceholder
: sharePointLists.isLoading
? texts.requestTypes.listNameLoading
: listNameOptions.length === 0
? texts.requestTypes.listNameEmpty
: texts.select.placeholder
}
onChange={e => setForm({ ...form, listName: e.target.value })}
/>
</Field>
</FormGrid> </FormGrid>
</FormModal> </FormModal>
)} )}
@@ -1,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 =>
@@ -1,23 +1,30 @@
import * as React from 'react'; import * as React from 'react';
import { import {
ComparisonOperator,
Employee, Employee,
HeadType, HeadType,
ProjectRole, ProjectRole,
RequestType,
RouteStepStatus, RouteStepStatus,
StepExecutorKind, StepExecutorKind,
TemplateRoute, TemplateRoute,
TemplateRouteStep TemplateRouteStep
} from '../../models'; } from '../../models';
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor'; import { detectExecutorKind } from '../../utils/stepExecutor';
import { toEmployeeOption } from '../../utils/employee'; import { toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { 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 {
api: ApprovalRouteApi; api: ApprovalRouteApi;
route: TemplateRoute; route: TemplateRoute;
/** Undefined, пока грузится тип заявки маршрута. */
requestType?: RequestType;
sharePoint: SharePointListsClient;
/** Undefined — создание нового шага, иначе редактирование существующего. */ /** Undefined — создание нового шага, иначе редактирование существующего. */
editing?: TemplateRouteStep; editing?: TemplateRouteStep;
nextOrderNo: number; nextOrderNo: number;
@@ -36,6 +43,10 @@ interface IFormState {
projectRole: ProjectRole; projectRole: ProjectRole;
headType: HeadType; headType: HeadType;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
hasCondition: boolean;
fieldName: string;
operation: ComparisonOperator;
value: string;
} }
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
@@ -49,6 +60,10 @@ const HEAD_TYPE_OPTIONS = Object.values(HeadType)
.filter(v => v !== HeadType.None) .filter(v => v !== HeadType.None)
.map(v => ({ value: v, label: headTypeLabels[v] })); .map(v => ({ value: v, label: headTypeLabels[v] }));
const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState { function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) { if (!editing) {
return { return {
@@ -59,7 +74,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
position: '', position: '',
projectRole: ProjectRole.ProjectManager, projectRole: ProjectRole.ProjectManager,
headType: HeadType.Chief, headType: HeadType.Chief,
allowReassignToOtherApprovers: false allowReassignToOtherApprovers: false,
hasCondition: false,
fieldName: '',
operation: ComparisonOperator.GreaterThan,
value: ''
}; };
} }
return { return {
@@ -70,7 +89,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
position: editing.position ?? '', position: editing.position ?? '',
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager, projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief, headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
hasCondition: Boolean(editing.fieldName),
fieldName: editing.fieldName ?? '',
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
value: editing.value ?? ''
}; };
} }
@@ -78,6 +101,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
api, api,
route, route,
requestType,
sharePoint,
editing, editing,
nextOrderNo, nextOrderNo,
employees, employees,
@@ -87,6 +112,35 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
}) => { }) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo)); const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const hasListName = Boolean(requestType?.listName);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
const fieldNameOptions = React.useMemo(() => {
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
options.unshift({ value: form.fieldName, label: form.fieldName });
}
return options;
}, [sharePointFields.items, form.fieldName]);
// Тип столбца известен, только если он есть среди подгруженных полей списка —
// для значения, введённого до появления поля в списке (или после его удаления),
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
const 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);
@@ -102,6 +156,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationPosition); submit.fail(texts.routeSteps.validationPosition);
return; return;
} }
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) {
submit.fail(texts.routeSteps.validationCondition);
return;
}
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber);
return;
}
const payload = { const payload = {
orderNo, orderNo,
@@ -110,7 +172,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null, executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
position: form.kind === 'position' ? form.position.trim() : null, position: form.kind === 'position' ? form.position.trim() : null,
projectRole: form.kind === 'projectRole' ? form.projectRole : null, projectRole: form.kind === 'projectRole' ? form.projectRole : null,
headType: form.kind === 'headType' ? form.headType : null headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null,
operation: form.hasCondition ? form.operation : null,
value: form.hasCondition ? form.value.trim() : null
}; };
const request = editing const request = editing
@@ -217,6 +282,55 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })} onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
/> />
</Field> </Field>
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListName ? texts.routeSteps.conditionDisabledHint : undefined}>
<Checkbox
label={texts.routeSteps.conditionCheckbox}
checked={form.hasCondition}
disabled={!hasListName}
onChange={v => setForm({ ...form, hasCondition: v })}
/>
</Field>
{form.hasCondition && hasListName && (
<>
<Field
label={texts.routeSteps.conditionFieldNameLabel}
required
full
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined}
>
<Select
options={fieldNameOptions}
value={form.fieldName}
disabled={sharePointFields.isLoading}
placeholder={
sharePointFields.isLoading
? texts.routeSteps.conditionFieldNameLoading
: fieldNameOptions.length === 0
? texts.routeSteps.conditionFieldNameEmpty
: texts.routeSteps.conditionFieldNamePlaceholder
}
onChange={e => setForm({ ...form, fieldName: e.target.value })}
/>
</Field>
<Field label={texts.routeSteps.conditionOperationLabel} required>
<Select
options={operationOptions}
value={form.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
/>
</Field>
<Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
</Field>
</>
)}
</FormGrid> </FormGrid>
</FormModal> </FormModal>
); );
@@ -2,9 +2,10 @@ import * as React from 'react';
import { RouteStepFormModal } from './RouteStepFormModal'; import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { SharePointListsClient } from '../../services/sharePointLists';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { describeStepExecutor } from '../../utils/stepExecutor'; import { routeStepStatusLabels, texts } from '../../texts';
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';
@@ -25,16 +26,19 @@ import {
export interface IRouteStepsScreenProps { export interface IRouteStepsScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
route: TemplateRoute; route: TemplateRoute;
requestTypeName: string; /** Undefined, пока грузится тип заявки (см. AdminApp.openSteps). */
requestType?: RequestType;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient; dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
onBack: () => void; onBack: () => void;
} }
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => { export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
const steps = useRouteSteps(api, route.id); const steps = useRouteSteps(api, route.id);
const employees = usePortalEmployees(dictionary); const employees = usePortalEmployees(dictionary);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>(); const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>(); const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
@@ -72,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={
<> <>
@@ -106,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>
) )
@@ -117,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: '',
@@ -140,6 +149,8 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
<RouteStepFormModal <RouteStepFormModal
api={api} api={api}
route={route} route={route}
requestType={requestType}
sharePoint={sharePoint}
editing={editing} editing={editing}
nextOrderNo={nextOrderNo} nextOrderNo={nextOrderNo}
employees={employees.items} employees={employees.items}
@@ -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,57 +169,62 @@ export function AsyncSearchableSelect<T>({
onClick={toggle} onClick={toggle}
disabled={disabled} disabled={disabled}
> >
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}> {value && selectedSublabel ? (
{value ? (selectedLabel ?? value) : placeholder} <span className={styles.valueStack}>
</span> <span className={styles.valueMain}>{selectedLabel ?? value}</span>
<span className={styles.valueSub}>{selectedSublabel}</span>
</span>
) : (
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
{value ? (selectedLabel ?? value) : placeholder}
</span>
)}
<Icon name="chevron-down" size="sm" className={styles.chevron} /> <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} placeholder={searchPlaceholder}
placeholder={searchPlaceholder} value={query}
value={query} onChange={e => setQuery(e.target.value)}
onChange={e => setQuery(e.target.value)} />
/> <div className={styles.list} ref={listRef}>
<div className={styles.list} ref={listRef}> {loading && <div className={styles.empty}>{texts.select.searching}</div>}
{loading && <div className={styles.empty}>{texts.select.searching}</div>} {!loading && error && <div className={styles.errorText}>{error}</div>}
{!loading && error && <div className={styles.errorText}>{error}</div>} {!loading && !error && items.length === 0 && (
{!loading && !error && items.length === 0 && ( <div className={styles.empty}>{texts.select.nothingFound}</div>
<div className={styles.empty}>{texts.select.nothingFound}</div> )}
)} {!loading && !error && items.map(item => {
{!loading && !error && items.map(item => { const itemValue = getValue(item);
const itemValue = getValue(item); const sub = getSublabel ? getSublabel(item) : undefined;
const sub = getSublabel ? getSublabel(item) : undefined; return (
return ( <button
<button type="button"
type="button" key={itemValue}
key={itemValue} className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`} onClick={() => pick(item)}
onClick={() => pick(item)} >
> {getLabel(item)}
{getLabel(item)} {sub && <span className={styles.itemSub}>{sub}</span>}
{sub && <span className={styles.itemSub}>{sub}</span>}
</button>
);
})}
{!loading && !error && hasMore && (
<div ref={sentinelRef} className={styles.sentinel}>
{loadingMore && 'Загрузка...'}
</div>
)}
</div>
{allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
{clearLabel}
</button> </button>
);
})}
{!loading && !error && hasMore && (
<div ref={sentinelRef} className={styles.sentinel}>
{loadingMore && 'Загрузка...'}
</div> </div>
)} )}
</div> </div>
)} {allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
{clearLabel}
</button>
</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;
setOpen(false); 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);
@@ -113,42 +116,40 @@ 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} placeholder={searchPlaceholder}
placeholder={searchPlaceholder} value={query}
value={query} onChange={e => setQuery(e.target.value)}
onChange={e => setQuery(e.target.value)} />
/> <div className={styles.list}>
<div className={styles.list}> {filtered.length === 0 && (
{filtered.length === 0 && ( <div className={styles.empty}>
<div className={styles.empty}> {effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
</div>
)}
{filtered.map(opt => (
<button
type="button"
key={opt.value}
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
onClick={() => pick(opt.value)}
>
{opt.label}
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
</button>
))}
</div>
{allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick('')}>
{clearLabel}
</button>
</div> </div>
)} )}
{filtered.map(opt => (
<button
type="button"
key={opt.value}
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
onClick={() => pick(opt.value)}
>
{opt.label}
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
</button>
))}
</div> </div>
)} {allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick('')}>
{clearLabel}
</button>
</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,41 @@
import * as React from 'react';
import { SharePointListField, SharePointListsClient } from '../services/sharePointLists';
export interface SharePointFieldsState {
items: SharePointListField[];
isLoading: boolean;
error?: string;
}
/** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */
export function useSharePointFields(
client: SharePointListsClient | undefined,
siteUrl: string | undefined | null,
listTitle: string | undefined | null
): SharePointFieldsState {
const [items, setItems] = React.useState<SharePointListField[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
if (!client || !siteUrl?.trim() || !listTitle?.trim()) {
setItems([]);
setError(undefined);
setIsLoading(false);
return undefined;
}
let cancelled = false;
setIsLoading(true);
setError(undefined);
client.listFields(siteUrl.trim(), listTitle.trim())
.then(result => { if (!cancelled) setItems(result); })
.catch((e: Error) => { if (!cancelled) { setItems([]); setError(e.message); } })
.finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; };
}, [client, siteUrl, listTitle]);
return { items, isLoading, error };
}
@@ -0,0 +1,45 @@
import * as React from 'react';
import { SharePointList, SharePointListsClient } from '../services/sharePointLists';
export interface SharePointListsState {
items: SharePointList[];
isLoading: boolean;
error?: string;
}
const DEBOUNCE_MS = 500;
/**
* Списки указанного сайта SharePoint — подгружаются автоматически по мере ввода
* site_url (с задержкой, чтобы не дёргать сайт на каждый символ).
*/
export function useSharePointLists(client: SharePointListsClient | undefined, siteUrl: string): SharePointListsState {
const [items, setItems] = React.useState<SharePointList[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
const trimmed = siteUrl.trim();
if (!client || !trimmed) {
setItems([]);
setError(undefined);
setIsLoading(false);
return undefined;
}
let cancelled = false;
setIsLoading(true);
setError(undefined);
const timer = setTimeout(() => {
client.listLists(trimmed)
.then(result => { if (!cancelled) setItems(result); })
.catch((e: Error) => { if (!cancelled) { setItems([]); setError(e.message); } })
.finally(() => { if (!cancelled) setIsLoading(false); });
}, DEBOUNCE_MS);
return () => { cancelled = true; clearTimeout(timer); };
}, [client, siteUrl]);
return { items, isLoading, error };
}
@@ -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',
@@ -28,3 +22,10 @@ export enum HeadType {
DirectionHead = 'DirectionHead', DirectionHead = 'DirectionHead',
DirectorTansu = 'DirectorTansu' DirectorTansu = 'DirectorTansu'
} }
export enum ComparisonOperator {
None = 'None',
GreaterThan = 'GreaterThan',
LessThan = 'LessThan',
Equal = 'Equal'
}
@@ -4,11 +4,19 @@ export interface RequestType {
id: number; id: number;
name: string; name: string;
pageUrl: string; pageUrl: string;
/** Имя списка SharePoint (Title) — источник field_name для шагов маршрута. */
listName: string | null;
/** Сайт SharePoint, где живёт listName. Заполнен тогда и только тогда, когда заполнен listName. */
siteUrl: string | null;
section: string;
} }
export interface CreateRequestTypeInput { export interface CreateRequestTypeInput {
name: string; name: string;
pageUrl: string; pageUrl: string;
listName: string | null;
siteUrl: string | null;
section: string;
} }
export type UpdateRequestTypeInput = CreateRequestTypeInput; export type UpdateRequestTypeInput = CreateRequestTypeInput;
@@ -16,4 +24,7 @@ export type UpdateRequestTypeInput = CreateRequestTypeInput;
export interface PatchRequestTypeInput { export interface PatchRequestTypeInput {
name?: string; name?: string;
pageUrl?: string; pageUrl?: string;
listName?: string | null;
siteUrl?: string | null;
section?: string;
} }
@@ -1,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,7 +1,7 @@
// DTO эндпоинта api/template-routes/{routeId}/steps // DTO эндпоинта api/template-routes/{routeId}/steps
// (Application/TemplateRouteSteps/*.cs). // (Application/TemplateRouteSteps/*.cs).
import { HeadType, ProjectRole, RouteStepStatus } from './enums'; import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
export interface TemplateRouteStep { export interface TemplateRouteStep {
id: number; id: number;
@@ -13,6 +13,11 @@ export interface TemplateRouteStep {
status: RouteStepStatus; status: RouteStepStatus;
routeId: number; routeId: number;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
/** Столбец списка SharePoint (internal name), по которому проверяется условие. */
fieldName: string | null;
operation: ComparisonOperator | null;
/** Значение для сравнения с fieldName. */
value: string | null;
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */ /** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
warning?: string | null; warning?: string | null;
} }
@@ -25,6 +30,9 @@ export interface CreateTemplateRouteStepInput {
headType: HeadType | null; headType: HeadType | null;
status: RouteStepStatus; status: RouteStepStatus;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
fieldName: string | null;
operation: ComparisonOperator | null;
value: string | null;
} }
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput; export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
@@ -37,6 +45,9 @@ export interface PatchTemplateRouteStepInput {
headType?: HeadType | null; headType?: HeadType | null;
status?: RouteStepStatus; status?: RouteStepStatus;
allowReassignToOtherApprovers?: boolean; allowReassignToOtherApprovers?: boolean;
fieldName?: string | null;
operation?: ComparisonOperator | null;
value?: string | null;
} }
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено // Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
@@ -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 = {
@@ -0,0 +1,150 @@
import { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/fields';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
// Списки, из которых типы заявок берут list_name/field_name, живут на произвольном
// сайте SharePoint (разном для разных типов заявок) — не обязательно на том, где
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
// См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт».
//
// Поиск сайтов (searchSites), наоборот, идёт через поисковый индекс SharePoint —
// он общий на весь тенант независимо от того, с какого сайта пришёл запрос,
// поэтому там клиент строится под текущий сайт веб-части (SPFx(context) без url).
export interface SharePointList {
id: string;
title: string;
}
export interface SharePointListField {
internalName: string;
title: string;
/** SharePoint TypeAsString (Text, Number, Currency, DateTime, Choice, ...). */
typeAsString: string;
}
export interface SharePointSiteSuggestion {
title: string;
url: string;
}
export interface SharePointSiteSearchResult {
items: SharePointSiteSuggestion[];
hasMore: boolean;
}
// SharePoint TypeAsString для колонок с числовыми значениями — только для них
// имеют смысл операторы «больше»/«меньше» (см. RouteStepFormModal).
const NUMERIC_FIELD_TYPES = new Set(['Number', 'Currency']);
export function isNumericFieldType(typeAsString: string): boolean {
return NUMERIC_FIELD_TYPES.has(typeAsString);
}
/** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */
export function formatSiteLabel(title: string | null | undefined, url: string): string {
return title ? `${title} — ${url}` : url;
}
interface IListItem {
Id: string;
Title: string;
}
interface IFieldItem {
InternalName: string;
Title: string;
TypeAsString: string;
}
interface ISearchResultRow {
Title?: string;
Path?: string;
}
function clientFor(context: WebPartContext, siteUrl: string): SPFI {
return spfi(siteUrl).using(SPFx(context));
}
export class SharePointListsClient {
// Заголовок сайта по site_url бэкенд не хранит (только сам url) — кэшируем
// на клиенте, чтобы не дёргать сайт заново на каждый рендер строки таблицы
// или переоткрытие формы с тем же значением.
private readonly siteTitleCache = new Map<string, string | null>();
constructor(private readonly context: WebPartContext) {}
/** Подмешать уже известный заголовок сайта (например, из результата поиска) — без похода в сеть. */
public cacheSiteTitle(siteUrl: string, title: string): void {
this.siteTitleCache.set(siteUrl, title);
}
/** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */
public async getSiteTitle(siteUrl: string): Promise<string | null> {
const cached = this.siteTitleCache.get(siteUrl);
if (cached !== undefined) return cached;
try {
const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string };
this.siteTitleCache.set(siteUrl, web.Title);
return web.Title;
} catch {
this.siteTitleCache.set(siteUrl, null);
return null;
}
}
/** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */
public async listLists(siteUrl: string): Promise<SharePointList[]> {
const sp = clientFor(this.context, siteUrl);
const items = await sp.web.lists
.select('Id', 'Title')
.filter('Hidden eq false and BaseTemplate eq 100')
.orderBy('Title')() as IListItem[];
return items.map(x => ({ id: x.Id, title: x.Title }));
}
/** Заголовки столбцов (внутреннее + отображаемое имя + тип) списка — исключая служебные поля. */
public async listFields(siteUrl: string, listTitle: string): Promise<SharePointListField[]> {
const sp = clientFor(this.context, siteUrl);
const items = await sp.web.lists.getByTitle(listTitle).fields
.select('InternalName', 'Title', 'TypeAsString')
.filter('Hidden eq false and ReadOnlyField eq false')
.orderBy('Title')() as IFieldItem[];
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 };
}
}
+59 -19
View File
@@ -4,19 +4,13 @@
// //
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались. // Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
import { 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]: 'Согласование',
@@ -38,6 +32,20 @@ export const headTypeLabels: Record<HeadType, string> = {
[HeadType.DirectorTansu]: 'Директор Tansu' [HeadType.DirectorTansu]: 'Директор Tansu'
}; };
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '—',
[ComparisonOperator.GreaterThan]: 'Больше (>)',
[ComparisonOperator.LessThan]: 'Меньше (<)',
[ComparisonOperator.Equal]: 'Равно (=)'
};
export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '',
[ComparisonOperator.GreaterThan]: '>',
[ComparisonOperator.LessThan]: '<',
[ComparisonOperator.Equal]: '='
};
export const texts = { export const texts = {
common: { common: {
home: 'Главная', home: 'Главная',
@@ -55,8 +63,6 @@ export const texts = {
}, },
nav: { nav: {
/** Буква в золотом квадрате логотипа — не зависит от заголовка панели. */
markLetter: 'T',
title: 'Панель настройки маршрутов', title: 'Панель настройки маршрутов',
dashboard: 'Дашборд', dashboard: 'Дашборд',
requestTypes: 'Типы заявок', requestTypes: 'Типы заявок',
@@ -96,6 +102,9 @@ export const texts = {
emptyList: 'Типов заявок пока нет.', emptyList: 'Типов заявок пока нет.',
columnName: 'Название', columnName: 'Название',
columnPageUrl: 'Url страницы', columnPageUrl: 'Url страницы',
columnSection: 'Раздел',
columnListName: 'Список SharePoint',
columnSiteUrl: 'Сайт SharePoint',
createTitle: 'Новый тип заявки', createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки', editTitle: 'Редактировать тип заявки',
nameLabel: 'Название', nameLabel: 'Название',
@@ -103,7 +112,20 @@ export const texts = {
pageUrlLabel: 'Url страницы', pageUrlLabel: 'Url страницы',
pageUrlHint: 'Страница формы заявки на портале.', pageUrlHint: 'Страница формы заявки на портале.',
pageUrlPlaceholder: '/pages/business-trip', pageUrlPlaceholder: '/pages/business-trip',
validationRequired: 'Заполните имя и Url страницы.', sectionLabel: 'Раздел',
sectionPlaceholder: 'Например, Кадры',
siteUrlLabel: 'Сайт SharePoint',
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
siteUrlSearchPlaceholder: 'Поиск сайта по названию...',
listNameLabel: 'Список SharePoint',
listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.',
listNamePlaceholder: 'Сначала укажите сайт SharePoint',
listNameLoading: 'Загрузка списков сайта...',
listNameEmpty: 'На этом сайте нет доступных списков',
listNameError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
validationRequired: 'Заполните имя, Url страницы и раздел.',
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
deleteTitle: 'Удалить тип заявки?', deleteTitle: 'Удалить тип заявки?',
deleteText: (name: string): string => deleteText: (name: string): string =>
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.` `Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
@@ -119,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: 'По проекту',
@@ -130,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: 'Сначала выберите компанию.',
@@ -146,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} и все его шаги будут удалены безвозвратно.`
@@ -155,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: 'Шагов пока нет — добавьте первый.',
@@ -164,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}`,
@@ -187,9 +207,22 @@ export const texts = {
headTypeLabel: 'Тип руководителя', headTypeLabel: 'Тип руководителя',
reassignLabel: 'Переназначение', reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим', reassignCheckbox: 'Разрешить переназначать другим согласующим',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
conditionFieldNameLabel: 'Поле списка',
conditionFieldNamePlaceholder: 'Выберите столбец списка',
conditionFieldNameLoading: 'Загрузка столбцов списка...',
conditionFieldNameEmpty: 'В списке нет доступных столбцов',
conditionFieldNameError: (message: string): string => `Не удалось получить столбцы списка: ${message}`,
conditionOperationLabel: 'Оператор сравнения',
conditionValueLabel: 'Значение',
conditionValuePlaceholder: 'Например, 1000',
validationOrderNo: 'Порядковый номер шага обязателен.', validationOrderNo: 'Порядковый номер шага обязателен.',
validationExecutor: 'Выберите сотрудника-исполнителя.', validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.', validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
deleteTitle: 'Удалить шаг?', deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
}, },
@@ -213,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}`;
}