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:
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
+15
-3
@@ -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);
|
||||||
|
|||||||
+47
-6
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-24
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
+217
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -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: "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-7
@@ -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");
|
||||||
|
|
||||||
|
|||||||
+35
-9
@@ -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);
|
||||||
|
|||||||
+191
-36
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+95
-22
@@ -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
|
||||||
|
|||||||
Generated
+71
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,14 +169,20 @@ export function AsyncSearchableSelect<T>({
|
|||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
{value && selectedSublabel ? (
|
||||||
|
<span className={styles.valueStack}>
|
||||||
|
<span className={styles.valueMain}>{selectedLabel ?? value}</span>
|
||||||
|
<span className={styles.valueSub}>{selectedSublabel}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
||||||
{value ? (selectedLabel ?? value) : placeholder}
|
{value ? (selectedLabel ?? value) : placeholder}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
||||||
<div className={styles.menu}>
|
|
||||||
<input
|
<input
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
@@ -210,8 +224,7 @@ export function AsyncSearchableSelect<T>({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DropdownPortal>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
export interface IDropdownPortalProps {
|
||||||
|
/** Элемент, под которым (или над которым) рисуется меню. */
|
||||||
|
anchorRef: React.RefObject<HTMLElement>;
|
||||||
|
open: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** Прокидывается наружу — нужен обработчику клика вне меню (портал живёт вне DOM-дерева root). */
|
||||||
|
menuRef?: React.Ref<HTMLDivElement>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPosition {
|
||||||
|
top?: number;
|
||||||
|
bottom?: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAP = 6;
|
||||||
|
const MIN_SPACE_BELOW = 240;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит выпадающее меню в document.body с position: fixed по координатам
|
||||||
|
* anchor-элемента, а не как обычный absolute-потомок. Внутри модалок наши формы
|
||||||
|
* скроллятся через overflow-y: auto на .box (Modal.module.scss) — обычный
|
||||||
|
* position: absolute-дочерний элемент обрезается этим скроллом, если список не
|
||||||
|
* помещается. Портал избегает этого, т.к. живёт вне скроллящегося поддерева.
|
||||||
|
*/
|
||||||
|
export const DropdownPortal: React.FC<IDropdownPortalProps> = ({ anchorRef, open, className, menuRef, children }) => {
|
||||||
|
const [position, setPosition] = React.useState<IPosition | undefined>();
|
||||||
|
|
||||||
|
const updatePosition = React.useCallback(() => {
|
||||||
|
const el = anchorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const openUp = spaceBelow < MIN_SPACE_BELOW && rect.top > spaceBelow;
|
||||||
|
|
||||||
|
setPosition(
|
||||||
|
openUp
|
||||||
|
? { bottom: window.innerHeight - rect.top + GAP, left: rect.left, width: rect.width }
|
||||||
|
: { top: rect.bottom + GAP, left: rect.left, width: rect.width }
|
||||||
|
);
|
||||||
|
}, [anchorRef]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
// capture: true — ловим скролл любого предка (в т.ч. .box модалки), не только window.
|
||||||
|
window.addEventListener('scroll', updatePosition, true);
|
||||||
|
window.addEventListener('resize', updatePosition);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
|
window.removeEventListener('resize', updatePosition);
|
||||||
|
};
|
||||||
|
}, [open, updatePosition]);
|
||||||
|
|
||||||
|
if (!open || !position) return null;
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
left: position.left,
|
||||||
|
width: position.width,
|
||||||
|
top: position.top,
|
||||||
|
bottom: position.bottom
|
||||||
|
};
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div ref={menuRef} className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,21 +29,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-shadow: $sh-2;
|
box-shadow: $sh-1;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
right: -60px;
|
|
||||||
top: -60px;
|
|
||||||
width: 220px;
|
|
||||||
height: 220px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(circle, rgba(212,160,30,.14), transparent 65%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
|
.phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
|
||||||
@@ -52,9 +41,9 @@
|
|||||||
width: 46px;
|
width: 46px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
border-radius: $r-md;
|
border-radius: $r-md;
|
||||||
background: rgba(212,160,30,.14);
|
background: $gold-100;
|
||||||
border: 1px solid rgba(212,160,30,.3);
|
border: 1px solid $gold-300;
|
||||||
color: $gold-300;
|
color: $gold-600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -63,7 +52,7 @@
|
|||||||
|
|
||||||
.phSub {
|
.phSub {
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: rgba(255,255,255,.55);
|
color: $gray-500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1.8px;
|
letter-spacing: 1.8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -73,7 +62,7 @@
|
|||||||
font-family: $font-serif;
|
font-family: $font-serif;
|
||||||
font-size: $fs-page-title;
|
font-size: $fs-page-title;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: $navy-900;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
letter-spacing: .2px;
|
letter-spacing: .2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,15 +35,39 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Двухстрочный триггер — название сверху, мелкая серая подпись (url) снизу,
|
||||||
|
// как itemSub в списке. Отдельно от .value, у которого одна строка целиком
|
||||||
|
// участвует в ellipsis и не может параллельно нести подпись другим кеглем.
|
||||||
|
.valueStack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueMain {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueSub {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $gray-400;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.placeholder { color: $gray-400; }
|
.placeholder { color: $gray-400; }
|
||||||
|
|
||||||
.chevron { color: $gray-400; flex-shrink: 0; }
|
.chevron { color: $gray-400; flex-shrink: 0; }
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
// position/top/left/width задаются инлайн из DropdownPortal (position: fixed
|
||||||
top: calc(100% + 6px);
|
// по координатам триггера) — портал рендерится в document.body, чтобы список
|
||||||
left: 0;
|
// не обрезался overflow-y: auto модалки (Modal.module.scss .box).
|
||||||
right: 0;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: $r-md;
|
border-radius: $r-md;
|
||||||
box-shadow: $sh-modal;
|
box-shadow: $sh-modal;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './SearchableSelect.module.scss';
|
import styles from './SearchableSelect.module.scss';
|
||||||
|
import { DropdownPortal } from './DropdownPortal';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
|
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
|
||||||
@@ -68,9 +70,10 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const onDocumentMouseDown = (e: MouseEvent): void => {
|
const onDocumentMouseDown = (e: MouseEvent): void => {
|
||||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
const target = e.target as Node;
|
||||||
|
if (rootRef.current?.contains(target)) return;
|
||||||
|
if (menuRef.current?.contains(target)) return;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const onKeyDown = (e: KeyboardEvent): void => {
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
if (e.key === 'Escape') setOpen(false);
|
if (e.key === 'Escape') setOpen(false);
|
||||||
@@ -113,8 +116,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
||||||
<div className={styles.menu}>
|
|
||||||
<input
|
<input
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
@@ -147,8 +149,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DropdownPortal>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
||||||
|
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||||
|
* квота) не ломает форму — просто не персистит.
|
||||||
|
*
|
||||||
|
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
|
||||||
|
* обновления в форму добавили новое поле, у пользователей со старой записью в
|
||||||
|
* localStorage оно не потеряется как undefined (что уронило бы рендер на
|
||||||
|
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
|
||||||
|
*/
|
||||||
|
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||||
|
const [value, setValue] = React.useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (!raw) return initial;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as T;
|
||||||
|
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
|
||||||
|
} catch {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// приватный режим / переполненная квота — не критично, просто не персистим
|
||||||
|
}
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user