Обходной лист (#12)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
@@ -4,9 +4,6 @@ namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
public static class AuthSchemes
|
||||
{
|
||||
/// <summary>Локальная разработка и тесты — Entra не настроен (см. AuthSetup).</summary>
|
||||
public const string Dev = "Dev";
|
||||
|
||||
/// <summary>Токен пользователя (делегированный) — обычная работа админки.</summary>
|
||||
public const string Bearer = JwtBearerDefaults.AuthenticationScheme;
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ public static class AuthSetup
|
||||
var tenantId = configuration["Entra:TenantId"];
|
||||
var audience = configuration["Entra:Audience"];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
|
||||
AddEntraAuth(services, configuration, tenantId, audience);
|
||||
else
|
||||
AddDevAuth(services);
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(audience))
|
||||
throw new InvalidOperationException(
|
||||
"Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет.");
|
||||
|
||||
AddEntraAuth(services, configuration, tenantId, audience);
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -125,26 +125,4 @@ public static class AuthSetup
|
||||
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddDevAuth(IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication(AuthSchemes.Dev)
|
||||
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
|
||||
|
||||
// Те же имена политик, но поверх Dev-схемы: иначе контроллер с
|
||||
// [Authorize(Policy = ...)] падал бы локально с «политика не найдена».
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any })
|
||||
{
|
||||
options.AddPolicy(name, policy =>
|
||||
{
|
||||
policy.AuthenticationSchemes.Add(AuthSchemes.Dev);
|
||||
policy.RequireAuthenticatedUser();
|
||||
});
|
||||
}
|
||||
|
||||
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
public sealed class DevAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IConfiguration configuration)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
|
||||
?? configuration["Dev:DefaultUserEmail"]
|
||||
?? "dev@tnsu.kz";
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"Entra": {
|
||||
"TenantId": "",
|
||||
"Audience": ""
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"AllowedAppIds": []
|
||||
},
|
||||
"Dev": {
|
||||
"DefaultUserEmail": "dev@tnsu.kz"
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"https://tnsukz.sharepoint.com",
|
||||
@@ -34,6 +31,7 @@
|
||||
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"ClientSecret": "",
|
||||
"Scope": "",
|
||||
"DirectorTansuEmail": "a.samayeva@tnsu.kz"
|
||||
"DirectorTansuEmail": "a.samayeva@tnsu.kz",
|
||||
"ManagingDirectorTansuEmail": "nkim@tnsu.kz"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
internal sealed record ApprovalRow(
|
||||
int Id,
|
||||
int TicketId,
|
||||
int StepOrder,
|
||||
string Status,
|
||||
string Phase,
|
||||
DateTimeOffset? Date,
|
||||
@@ -31,7 +30,6 @@ internal sealed record ApprovalRow(
|
||||
return new ApprovalRow(
|
||||
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||
ticketId,
|
||||
TryGetInt(item, "StepOrder", out var order) ? order : 0,
|
||||
GetString(item, "ApprovalStatus") ?? string.Empty,
|
||||
// Пустая фаза — старые строки, заведённые до появления колонки;
|
||||
// трактуем как «Согласование» (так же поступает request-portal).
|
||||
|
||||
@@ -23,10 +23,6 @@ public sealed class ReminderService(
|
||||
IOptions<RemindersOptions> options,
|
||||
ILogger<ReminderService> logger)
|
||||
{
|
||||
// Ограничение на длину OData-фильтра: Id заявок подставляются пачками, иначе
|
||||
// на большом списке получится неподъёмной длины URL.
|
||||
private const int IdChunkSize = 50;
|
||||
|
||||
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
|
||||
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
|
||||
private const string ActiveStatusFilter =
|
||||
@@ -81,7 +77,7 @@ public sealed class ReminderService(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Шаг 1. Активные строки — прямым $filter по полю ApprovalStatus.
|
||||
// Активные строки — прямым $filter по полю ApprovalStatus.
|
||||
//
|
||||
// Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
|
||||
// тот же приём, что в request-portal (см. MP_STATUS). На практике он
|
||||
@@ -91,29 +87,28 @@ public sealed class ReminderService(
|
||||
// подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
|
||||
// он не отстаёт от реального состояния списка (не зависит от индексации
|
||||
// вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
|
||||
//
|
||||
// Дата активации строки — прямо в ней самой (колонка Date): request-portal
|
||||
// проставляет её в момент перехода строки в «На согласовании»/«На
|
||||
// исполнении» (см. approvalList.service.ts/setActive) и никак иначе,
|
||||
// поэтому больше не нужно ни читать весь маршрут заявки, ни дату
|
||||
// создания заявки, ни выводить активацию из решений соседних шагов.
|
||||
var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
|
||||
if (active.Count == 0) return [];
|
||||
|
||||
// Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута —
|
||||
// дата активации шага выводится из дат решений предыдущего шага.
|
||||
var ticketIds = active.Select(r => r.TicketId).Distinct().ToList();
|
||||
var allRows = await ReadRowsByTicketsAsync(siteUrl, approvalListTitle, ticketIds, cancellationToken);
|
||||
var rowsByTicket = allRows.GroupBy(r => r.TicketId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var createdByTicket = await ReadCreatedAsync(siteUrl, type.ListId!, ticketIds, cancellationToken);
|
||||
|
||||
var reminders = new List<ReminderDto>();
|
||||
foreach (var row in active)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue;
|
||||
if (!rowsByTicket.TryGetValue(row.TicketId, out var ticketRows)) continue;
|
||||
|
||||
createdByTicket.TryGetValue(row.TicketId, out var created);
|
||||
var activatedAt = ActivatedAt(row, ticketRows, created);
|
||||
if (activatedAt is null)
|
||||
if (row.Date is not { } activatedAt)
|
||||
{
|
||||
// Строка активна, но Date пуста — так бывает у строк, заведённых
|
||||
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
||||
// почему-то не проставил её при активации. Без даты старта SLA не
|
||||
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
||||
logger.LogWarning(
|
||||
"Не удалось определить дату активации строки {Row} заявки {Ticket} — пропущена",
|
||||
"Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена",
|
||||
row.Id, row.TicketId);
|
||||
continue;
|
||||
}
|
||||
@@ -121,7 +116,7 @@ public sealed class ReminderService(
|
||||
// SLA строки перекрывает типовой. У строк, созданных до появления
|
||||
// колонки, значения нет — тогда работает SLA типа заявки.
|
||||
var slaHours = row.SlaHours ?? type.SlaHours!.Value;
|
||||
var deadline = activatedAt.Value.AddHours(slaHours);
|
||||
var deadline = activatedAt.AddHours(slaHours);
|
||||
if (deadline >= now) continue;
|
||||
|
||||
reminders.Add(new ReminderDto(
|
||||
@@ -137,51 +132,6 @@ public sealed class ReminderService(
|
||||
return reminders;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Момент, с которого шаг считается активным, — начало отсчёта SLA.
|
||||
///
|
||||
/// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это
|
||||
/// точечное обновление статуса, дату оно не пишет), поэтому момент выводится
|
||||
/// из соседних строк:
|
||||
/// • первый шаг согласования — дата создания заявки;
|
||||
/// • первый шаг исполнения — последнее решение по фазе согласования
|
||||
/// (фаза исполнения активируется только когда согласование пройдено целиком);
|
||||
/// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы.
|
||||
///
|
||||
/// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить
|
||||
/// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг
|
||||
/// активируется только после решения последнего из них.
|
||||
///
|
||||
/// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не
|
||||
/// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по
|
||||
/// условию появления).
|
||||
/// </summary>
|
||||
private static DateTimeOffset? ActivatedAt(
|
||||
ApprovalRow row, IReadOnlyList<ApprovalRow> ticketRows, DateTimeOffset? requestCreated)
|
||||
{
|
||||
var samePhase = ticketRows.Where(r => r.Phase == row.Phase).ToList();
|
||||
var isFirstInPhase = samePhase.All(r => r.StepOrder >= row.StepOrder);
|
||||
|
||||
if (isFirstInPhase)
|
||||
{
|
||||
return row.Phase == ApprovalPhases.Execution
|
||||
? LastDecisionOf(ticketRows.Where(r => r.Phase == ApprovalPhases.Approval))
|
||||
: requestCreated;
|
||||
}
|
||||
|
||||
var previousOrder = samePhase
|
||||
.Where(r => r.StepOrder < row.StepOrder)
|
||||
.Max(r => r.StepOrder);
|
||||
|
||||
return LastDecisionOf(samePhase.Where(r => r.StepOrder == previousOrder));
|
||||
}
|
||||
|
||||
private static DateTimeOffset? LastDecisionOf(IEnumerable<ApprovalRow> rows)
|
||||
{
|
||||
var dates = rows.Select(r => r.Date).Where(d => d is not null).Select(d => d!.Value).ToList();
|
||||
return dates.Count == 0 ? null : dates.Max();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
|
||||
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
|
||||
@@ -207,7 +157,7 @@ public sealed class ReminderService(
|
||||
}
|
||||
|
||||
private const string RowSelect =
|
||||
"$select=Id,ApprovalStatus,StepOrder,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000";
|
||||
|
||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||
@@ -219,43 +169,6 @@ public sealed class ReminderService(
|
||||
return Parse(items);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
|
||||
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = new List<ApprovalRow>();
|
||||
foreach (var chunk in ticketIds.Chunk(IdChunkSize))
|
||||
{
|
||||
var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"TicketId eq {id}"));
|
||||
rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>Даты создания заявок — начало отсчёта для первого шага согласования.</summary>
|
||||
private async Task<Dictionary<int, DateTimeOffset>> ReadCreatedAsync(
|
||||
string siteUrl, string listUrl, IReadOnlyList<int> ids, CancellationToken cancellationToken)
|
||||
{
|
||||
var created = new Dictionary<int, DateTimeOffset>();
|
||||
|
||||
foreach (var chunk in ids.Chunk(IdChunkSize))
|
||||
{
|
||||
var query = "$select=Id,Created&$top=5000&$filter=" +
|
||||
string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
||||
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listUrl, query, cancellationToken);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!item.TryGetProperty("Id", out var idValue) || !idValue.TryGetInt32(out var id)) continue;
|
||||
if (!item.TryGetProperty("Created", out var createdValue)) continue;
|
||||
if (createdValue.ValueKind != JsonValueKind.String) continue;
|
||||
if (DateTimeOffset.TryParse(createdValue.GetString(), out var parsed)) created[id] = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private static List<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
||||
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||
}
|
||||
|
||||
@@ -163,16 +163,42 @@ public sealed class TemplateRouteService(
|
||||
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Синтетическое поле условия — переопределяет то, что мог прислать вызывающий
|
||||
// код под тем же именем. Считаем НЕ по должности инициатора из справочника
|
||||
// api.tnsu.kz (employee.Position — она у самого сотрудника чаще всего не
|
||||
// «руководитель»), а по полю Position, заполненному В САМОЙ АНКЕТЕ (например,
|
||||
// «Должность» в Обходном листе — там указывают должность увольняемого, а не
|
||||
// того, кто отправил заявку). Это свободный текст, поэтому определяем
|
||||
// «руководитель или нет» по ключевым словам в нём. Нет такого поля в заявке —
|
||||
// считаем «нет» (а не подставляем должность инициатора как фолбэк).
|
||||
fieldValues.TryGetValue(PositionField, out var formPosition);
|
||||
fieldValues[ManagerConditionField] = IsManagerPosition(formPosition) ? "true" : "false";
|
||||
|
||||
// Должность в заявке не передана вовсе — условие по IsManager вычислить не из
|
||||
// чего. Такой шаг НЕ выбрасываем: молча потерять согласующего хуже, чем показать
|
||||
// лишнего, поэтому шаг попадает в результат и несёт предупреждение (см. ниже
|
||||
// ResolveStepAsync/conditionWarning). Пустая строка или должность, не похожая на
|
||||
// руководящую, — это осознанный «нет», предупреждать не о чем.
|
||||
var positionMissing = string.IsNullOrWhiteSpace(formPosition);
|
||||
|
||||
var matchingSteps = route.Steps
|
||||
.Where(s => s.Status == request.Status)
|
||||
.Where(s => MatchesCondition(s, fieldValues))
|
||||
.Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||||
.OrderBy(s => s.OrderNo)
|
||||
.ToList();
|
||||
|
||||
var steps = new List<ResolvedRouteStepDto>();
|
||||
foreach (var step in matchingSteps)
|
||||
steps.Add(await ResolveStepAsync(
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, cancellationToken));
|
||||
// Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные
|
||||
// походы в api.tnsu.kz (см. ResolveStepAsync), и на длинном маршруте
|
||||
// (десяток+ шагов) последовательные await один за другим складываются в
|
||||
// заметную задержку. Task.WhenAll сохраняет порядок результатов (массив
|
||||
// соответствует порядку задач, а не порядку завершения), поэтому
|
||||
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
|
||||
// дубликат считается первым) продолжают работать так же, как при
|
||||
// последовательном foreach.
|
||||
var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
|
||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||
cancellationToken)));
|
||||
|
||||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||
|
||||
@@ -214,9 +240,16 @@ public sealed class TemplateRouteService(
|
||||
x.ByProject == byProject,
|
||||
cancellationToken);
|
||||
|
||||
/// <param name="conditionWarning">
|
||||
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||||
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||
/// </param>
|
||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||
string? conditionWarning,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? resolvedEmail;
|
||||
@@ -264,6 +297,7 @@ public sealed class TemplateRouteService(
|
||||
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
|
||||
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
|
||||
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
|
||||
_ => null
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
@@ -286,13 +320,16 @@ public sealed class TemplateRouteService(
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
|
||||
return new ResolvedRouteStepDto(
|
||||
step.Id,
|
||||
step.OrderNo,
|
||||
resolvedEmail,
|
||||
resolvedPosition,
|
||||
step.AllowReassignToOtherApprovers,
|
||||
warning,
|
||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason);
|
||||
}
|
||||
@@ -328,6 +365,41 @@ public sealed class TemplateRouteService(
|
||||
private static bool TryParseNumber(string? value, out double number) =>
|
||||
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||||
|
||||
/// <summary>Поле заявки, из которого вычисляется <see cref="ManagerConditionField"/>.</summary>
|
||||
private const string PositionField = "Position";
|
||||
|
||||
/// <summary>Синтетическое поле условия шага — вычисляется, а не приходит из заявки.</summary>
|
||||
private const string ManagerConditionField = "IsManager";
|
||||
|
||||
private const string PositionMissingWarning =
|
||||
"В заявке не передана должность (поле «Position») — условие шага по «IsManager» вычислить не из чего, " +
|
||||
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
|
||||
|
||||
/// <summary>
|
||||
/// Условие шага завязано на IsManager, но вычислить его не из чего: должность
|
||||
/// в заявке не передана. Такой шаг попадает в результат вопреки условию и с
|
||||
/// предупреждением — см. ResolveAsync.
|
||||
/// </summary>
|
||||
private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) =>
|
||||
positionMissing &&
|
||||
string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) &&
|
||||
step.Operation is not (null or ComparisonOperator.None);
|
||||
|
||||
/// <summary>
|
||||
/// Должность — свободный текст без структуры (нет отдельного флага «это
|
||||
/// руководитель»), поэтому определяем это по ключевым словам в её названии.
|
||||
/// Список подобран по фактическим должностям в справочнике api.tnsu.kz —
|
||||
/// Начальник/Руководитель/Директор/Заместитель/Главный/Заведующий складом.
|
||||
/// «Ведущий», «Старший» и «Менеджер» намеренно не входят: это старший
|
||||
/// специалист, а не должность с подчинёнными.
|
||||
/// </summary>
|
||||
private static readonly string[] ManagerPositionKeywords =
|
||||
["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"];
|
||||
|
||||
private static bool IsManagerPosition(string? position) =>
|
||||
!string.IsNullOrWhiteSpace(position) &&
|
||||
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
{
|
||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -6,6 +6,7 @@ public enum HeadType
|
||||
Chief,
|
||||
DirectionHead,
|
||||
DirectorTansu,
|
||||
ManagingDirectorTansu,
|
||||
|
||||
/// <summary>
|
||||
/// Руководитель отдела, указанного явно в запросе на resolve (см.
|
||||
|
||||
+7
-1
@@ -29,11 +29,17 @@ public static class InfrastructureServiceCollectionExtensions
|
||||
|
||||
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
||||
services.AddHttpClient<TnsuPortalTokenProvider>();
|
||||
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
|
||||
// ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут,
|
||||
// см. её комментарий) — сам HttpTnsuPortalClient регистрируем по
|
||||
// конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить
|
||||
// как обычную зависимость.
|
||||
services.AddHttpClient<HttpTnsuPortalClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ITnsuPortalClient, CachingTnsuPortalClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
/// <summary>
|
||||
/// Кеширующая обёртка над <see cref="HttpTnsuPortalClient"/>. Резолв одного
|
||||
/// маршрута (TemplateRouteService.ResolveAsync) может дёргать справочник
|
||||
/// api.tnsu.kz по многу раз за один запрос — один и тот же согласующий,
|
||||
/// должность или руководитель нередко повторяется в разных шагах длинного
|
||||
/// маршрута, — и без кеша распараллеливание самих шагов резолва не спасает от
|
||||
/// нагрузки на api.tnsu.kz: он просто получает то же число запросов
|
||||
/// одновременно вместо по очереди. Кеш процессный (<see cref="IMemoryCache"/>,
|
||||
/// не распределённый — реплик API несколько, поэтому в них он не общий), TTL
|
||||
/// фиксирован на <see cref="CacheDuration"/>.
|
||||
/// </summary>
|
||||
public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryCache cache) : ITnsuPortalClient
|
||||
{
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
GetOrAddAsync(
|
||||
$"emp:email:{email.ToLowerInvariant()}",
|
||||
() => inner.GetEmployeeByEmailAsync(email, cancellationToken));
|
||||
|
||||
public Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) =>
|
||||
GetOrAddAsync(
|
||||
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
|
||||
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
|
||||
|
||||
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
||||
GetOrAddAsync(
|
||||
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
||||
() => inner.GetManagerAsync(email, managerType, cancellationToken));
|
||||
|
||||
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
|
||||
GetOrAddAsync(
|
||||
$"projexec:{projectId}",
|
||||
() => inner.GetProjectExecutorsAsync(projectId, cancellationToken));
|
||||
|
||||
public Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) =>
|
||||
GetOrAddAsync(
|
||||
$"depthead:{departmentId}",
|
||||
() => inner.GetDepartmentHeadEmailAsync(departmentId, cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// Кеширует и «не нашли» (null / пустой список) — повторять запрос к
|
||||
/// api.tnsu.kz за тем же результатом на каждый одинаковый шаг маршрута
|
||||
/// незачем. Ключ по одному и тому же значению может задеть гонку: два
|
||||
/// конкурентных шага (см. Task.WhenAll в ResolveAsync) с одинаковым ключом
|
||||
/// оба не найдут его в кеше и оба сходят в API — не страшно, это лишний
|
||||
/// один поход, а не порча данных; блокировка per-key усложнила бы код ради
|
||||
/// выигрыша, который при TTL в минуты и так почти всегда есть без неё.
|
||||
/// </summary>
|
||||
private async Task<T> GetOrAddAsync<T>(string key, Func<Task<T>> factory)
|
||||
{
|
||||
if (cache.TryGetValue(key, out T? cached))
|
||||
return cached!;
|
||||
|
||||
var value = await factory();
|
||||
cache.Set(key, value, CacheDuration);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ public sealed class TnsuPortalOptions
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz";
|
||||
public string ManagingDirectorTansuEmail { get; set; } = "nkim@tnsu.kz";
|
||||
}
|
||||
|
||||
+45
-87
@@ -13,9 +13,11 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA:
|
||||
/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент
|
||||
/// выводится из соседних строк — это самая хрупкая часть эндпоинта.
|
||||
/// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки
|
||||
/// (колонка Date — request-portal проставляет её в момент, когда строка
|
||||
/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше
|
||||
/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты
|
||||
/// и проверяют вместо прежнего вывода даты из соседних строк.
|
||||
///
|
||||
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||||
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
|
||||
@@ -53,13 +55,12 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||
|
||||
private static object Row(
|
||||
int id, int ticketId, int stepOrder, string status, string phase,
|
||||
string? date = null, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
|
||||
int id, int ticketId, string status, string phase,
|
||||
string? date, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
|
||||
new
|
||||
{
|
||||
Id = id,
|
||||
TicketId = ticketId,
|
||||
StepOrder = stepOrder,
|
||||
ApprovalStatus = status,
|
||||
Phase = phase,
|
||||
Date = date,
|
||||
@@ -68,8 +69,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
TansuSiteApprover = new { EMail = email }
|
||||
};
|
||||
|
||||
private ReminderService BuildService(
|
||||
string listUrl, IReadOnlyList<object> allRows, DateTimeOffset requestCreated)
|
||||
// Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) —
|
||||
// мок отвечает так же: по значению статуса, а не по признаку решённости.
|
||||
// Никаких запросов ни по TicketId, ни к списку данных за Created сервис
|
||||
// больше не делает — если он вдруг снова начнёт, немонастроенный мок
|
||||
// (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой,
|
||||
// а не тихо проигнорирует лишний запрос.
|
||||
private ReminderService BuildService(string listUrl, IReadOnlyList<object> rows)
|
||||
{
|
||||
var sharePoint = new Mock<ISharePointClient>();
|
||||
|
||||
@@ -77,9 +83,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DataListTitle);
|
||||
|
||||
// Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок
|
||||
// отвечает так же: по значению статуса, а не по признаку решённости.
|
||||
var activeRows = allRows
|
||||
var activeRows = rows
|
||||
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
|
||||
"На согласовании" or "На исполнении")
|
||||
.ToList();
|
||||
@@ -88,15 +92,6 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(activeRows.Select(Json).ToList());
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListTitleAsync(
|
||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(allRows.Select(Json).ToList());
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]);
|
||||
|
||||
return new ReminderService(
|
||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||
sharePoint.Object,
|
||||
@@ -104,14 +99,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
NullLogger<ReminderService>.Instance);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Первый шаг согласования: SLA отсчитывается от создания заявки")]
|
||||
public async Task FirstApprovalStep_CountsFromRequestCreated()
|
||||
[Fact(DisplayName = "SLA отсчитывается от Date активной строки")]
|
||||
public async Task ActiveRow_CountsFromOwnDate()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla first step", slaHours: 24);
|
||||
// Создана 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla own date", slaHours: 24);
|
||||
// Строка стала активной 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-48));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
@@ -127,70 +121,43 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-2));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-2).ToString("O"))]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Второй шаг: SLA отсчитывается от решения предыдущего шага, а не от создания заявки")]
|
||||
public async Task SecondStep_CountsFromPreviousStepDecision()
|
||||
[Fact(DisplayName = "Другие строки того же маршрута сервису не нужны — читается только активная")]
|
||||
public async Task OtherRoutRows_AreIrrelevant()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla second step", slaHours: 10);
|
||||
// Заявка создана давно, но предыдущий шаг решён 12 часов назад:
|
||||
// при отсчёте от создания просрочка была бы огромной, верная — 2 часа.
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla ignores history", slaHours: 10);
|
||||
// Решённые строки маршрута заведомо не попадут в ответ на $filter по
|
||||
// ApprovalStatus (мок это тоже соблюдает) — но даже будь они прочитаны,
|
||||
// сервис их дату уже не использует: отсчёт идёт только от Date
|
||||
// активной строки.
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")),
|
||||
Row(11, 1, 2, "На согласовании", "Согласование", email: "second@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
var reminder = result.Value.ShouldHaveSingleItem();
|
||||
reminder.Email.ShouldBe("second@tnsu.kz");
|
||||
reminder.HoursOverdue.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Параллельный предыдущий шаг: отсчёт от ПОСЛЕДНЕГО решения, а не от первого")]
|
||||
public async Task ParallelPreviousStep_CountsFromLastDecision()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla parallel", slaHours: 10);
|
||||
// Двое делят StepOrder = 1. Следующий шаг активируется только после
|
||||
// решения второго (-12 ч), а не первого (-40 ч).
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
|
||||
Row(11, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")),
|
||||
Row(12, 1, 2, "На согласовании", "Согласование", email: "next@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
Row(10, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
|
||||
Row(11, 1, "На согласовании", "Согласование", date: Now.AddHours(-12).ToString("O"))
|
||||
]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Первый шаг исполнения: отсчёт от последнего решения фазы согласования")]
|
||||
public async Task FirstExecutionStep_CountsFromLastApprovalDecision()
|
||||
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
||||
public async Task ActiveRow_WithoutDate_IsSkipped()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla execution", slaHours: 5);
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla missing date", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-30).ToString("O")),
|
||||
Row(11, 1, 2, "Согласовано", "Согласование", date: Now.AddHours(-8).ToString("O")),
|
||||
Row(12, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: null)]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
var reminder = result.Value.ShouldHaveSingleItem();
|
||||
reminder.Email.ShouldBe("executor@tnsu.kz");
|
||||
reminder.HoursOverdue.ShouldBe(3);
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Value.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")]
|
||||
@@ -199,8 +166,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla row override", slaHours: 100);
|
||||
// По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов.
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1)],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1)]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
@@ -220,8 +186,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
// Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и
|
||||
// пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не
|
||||
// воспроизводит.
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1.0m)],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1.0m)]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
@@ -233,8 +198,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
@@ -249,8 +213,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 11, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
[Row(10, 11, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
@@ -264,11 +227,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla exec nav", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-6).ToString("O")),
|
||||
Row(11, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
[Row(10, 1, "На исполнении", "Исполнение", date: Now.AddHours(-6).ToString("O"), email: "executor@tnsu.kz")]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
@@ -281,10 +240,9 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla queued", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "На согласовании", "Согласование"),
|
||||
Row(11, 1, 2, "Ждёт очереди", "Согласование", email: "waiting@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O")),
|
||||
Row(11, 1, "Ждёт очереди", "Согласование", date: null, email: "waiting@tnsu.kz")
|
||||
]);
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import {
|
||||
type IPropertyPaneConfiguration,
|
||||
PropertyPaneTextField,
|
||||
PropertyPaneToggle
|
||||
} from '@microsoft/sp-property-pane';
|
||||
import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||
|
||||
import ApprovalAdmin from './components/ApprovalAdmin';
|
||||
@@ -13,8 +9,6 @@ import { IApprovalAdminProps } from './components/IApprovalAdminProps';
|
||||
|
||||
export interface IApprovalAdminWebPartProps {
|
||||
apiBaseUrl: string;
|
||||
useDevAuth: boolean;
|
||||
devUserEmail: string;
|
||||
aadResourceId: string;
|
||||
portalBaseUrl: string;
|
||||
}
|
||||
@@ -25,8 +19,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
||||
const element: React.ReactElement<IApprovalAdminProps> = React.createElement(ApprovalAdmin, {
|
||||
context: this.context,
|
||||
apiBaseUrl: this.properties.apiBaseUrl,
|
||||
useDevAuth: this.properties.useDevAuth,
|
||||
devUserEmail: this.properties.devUserEmail,
|
||||
aadResourceId: this.properties.aadResourceId,
|
||||
portalBaseUrl: this.properties.portalBaseUrl
|
||||
});
|
||||
@@ -39,11 +31,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
||||
// Для локальной разработки переопределите значение в панели свойств веб-части
|
||||
// (например, на http://localhost:5080 — порт из backend/.../launchSettings.json).
|
||||
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route';
|
||||
// Dev-режим ломает /resolve: наш бэкенд не получает Bearer-токен пользователя
|
||||
// для пересылки в api.tnsu.kz и падает на собственном client_credentials
|
||||
// (TnsuPortal:ClientSecret в appsettings.json пуст). По умолчанию — реальный AAD.
|
||||
this.properties.useDevAuth = this.properties.useDevAuth ?? false;
|
||||
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
|
||||
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
|
||||
return Promise.resolve();
|
||||
@@ -72,15 +59,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
||||
label: 'Api Base Url',
|
||||
description: 'По умолчанию — прод (https://api.tnsu.kz/approval-route). Для локальной разработки укажите http://localhost:5080.'
|
||||
}),
|
||||
PropertyPaneToggle('useDevAuth', {
|
||||
label: 'Локальный Dev-режим (без Azure AD)',
|
||||
onText: 'Вкл. (заголовок X-Dev-User-Email)',
|
||||
offText: 'Выкл. (AAD-токен через SPFx)'
|
||||
}),
|
||||
PropertyPaneTextField('devUserEmail', {
|
||||
label: 'Dev: email пользователя',
|
||||
description: 'Используется только при включённом Dev-режиме.'
|
||||
}),
|
||||
PropertyPaneTextField('aadResourceId', {
|
||||
label: 'AAD Resource Id (App ID URI)',
|
||||
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
|
||||
|
||||
@@ -3,7 +3,7 @@ import styles from './ApprovalAdmin.module.scss';
|
||||
import type { IApprovalAdminProps } from './IApprovalAdminProps';
|
||||
import { AdminApp } from './app/AdminApp';
|
||||
import { ApprovalRouteApi } from '../services/api';
|
||||
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||
import { AadApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||
import { SharePointListsClient } from '../services/sharePointLists';
|
||||
import { RequestManageService } from '../services/requestManageService';
|
||||
@@ -21,9 +21,6 @@ function ensureFontsLoaded(): void {
|
||||
}
|
||||
|
||||
async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient> {
|
||||
if (props.useDevAuth) {
|
||||
return new DevApiHttpClient(props.apiBaseUrl, props.devUserEmail || 'dev@tnsu.kz');
|
||||
}
|
||||
const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId);
|
||||
return new AadApiHttpClient(aadClient, props.apiBaseUrl);
|
||||
}
|
||||
@@ -50,11 +47,8 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]);
|
||||
}, [props.apiBaseUrl, props.aadResourceId]);
|
||||
|
||||
// Справочники всегда идут через AAD, независимо от Dev-режима нашего API:
|
||||
// api.tnsu.kz принимает только настоящий токен, заголовок X-Dev-User-Email
|
||||
// ему ничего не говорит.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDictionary(undefined);
|
||||
|
||||
@@ -3,8 +3,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
export interface IApprovalAdminProps {
|
||||
context: WebPartContext;
|
||||
apiBaseUrl: string;
|
||||
useDevAuth: boolean;
|
||||
devUserEmail: string;
|
||||
aadResourceId: string;
|
||||
portalBaseUrl: string;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
api.requestTypes.get(r.requestTypeId)
|
||||
.then(setSelectedRequestType)
|
||||
.catch(() => setSelectedRequestType(undefined));
|
||||
navigate({ tab: 'routes', routeId: r.id });
|
||||
// routesFilter несём с собой: возврат «к маршрутам» должен показать тот же
|
||||
// отфильтрованный список, из которого ушли (см. onBack ниже).
|
||||
navigate({ tab: 'routes', routeId: r.id, routesFilter: route.routesFilter });
|
||||
};
|
||||
|
||||
// Открытие по прямой ссылке / обновление страницы / кнопка «назад»: в URL
|
||||
@@ -79,7 +81,12 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||
{route.tab === 'routes' && !route.routeId && (
|
||||
<RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />
|
||||
<RoutesScreen
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
initialFilterRequestTypeId={route.routesFilter}
|
||||
onOpenSteps={openSteps}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
||||
<RouteStepsScreen
|
||||
@@ -88,7 +95,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
requestType={selectedRequestType}
|
||||
dictionary={dictionary}
|
||||
sharePoint={sharePoint}
|
||||
onBack={() => navigate({ tab: 'routes' })}
|
||||
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'resolve' && (
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Company,
|
||||
CreateTemplateRouteStepInput,
|
||||
Department,
|
||||
ProjectRole,
|
||||
TemplateRoute,
|
||||
TemplateRouteStep
|
||||
} from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { shortGuid } from '../../utils/guid';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { ApiClientError } from '../../services/httpClient';
|
||||
import { Alert, Checkbox, Field, FormGrid, FormModal, SearchableSelect } from '../ui';
|
||||
|
||||
export interface IRouteCopyModalProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Маршрут-источник: тип заявки у копии тот же, меняются только «координаты». */
|
||||
source: TemplateRoute;
|
||||
requestTypeName: string;
|
||||
companies: Company[];
|
||||
departments: Department[];
|
||||
dictionaryLoading: boolean;
|
||||
onCopied: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface IFormState {
|
||||
companyId: string;
|
||||
departmentId: string;
|
||||
byProject: boolean;
|
||||
requiresControl: boolean;
|
||||
}
|
||||
|
||||
/** Тело для создания шага-копии: всё, кроме id/routeId, переносится как есть. */
|
||||
function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
|
||||
return {
|
||||
orderNo: step.orderNo,
|
||||
executorEmail: step.executorEmail,
|
||||
position: step.position,
|
||||
projectRole: step.projectRole,
|
||||
headType: step.headType,
|
||||
status: step.status,
|
||||
allowReassignToOtherApprovers: step.allowReassignToOtherApprovers,
|
||||
fieldName: step.fieldName,
|
||||
operation: step.operation,
|
||||
value: step.value,
|
||||
slaHours: step.slaHours,
|
||||
askOverdueReason: step.askOverdueReason
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Копирование маршрута вместе с шагами в пределах ОДНОГО типа заявки.
|
||||
*
|
||||
* Маршрут уникален по (тип заявки, компания, отдел, «по проекту») — см.
|
||||
* HasDuplicateAsync в TemplateRouteService. Тип заявки у копии тот же, поэтому
|
||||
* копия обязана отличаться хотя бы одним из остальных трёх полей; проверяем это
|
||||
* до запроса, чтобы вместо 409 от бэкенда показать понятную причину.
|
||||
*
|
||||
* Шаги переносятся по одному. Если хоть один не создался, СОЗДАННЫЙ МАРШРУТ
|
||||
* УДАЛЯЕТСЯ: наполовину скопированный маршрут хуже, чем отсутствующий — он бы
|
||||
* молча резолвился с недостающими согласующими.
|
||||
*/
|
||||
export const RouteCopyModal: React.FC<IRouteCopyModalProps> = ({
|
||||
api,
|
||||
source,
|
||||
requestTypeName,
|
||||
companies,
|
||||
departments,
|
||||
dictionaryLoading,
|
||||
onCopied,
|
||||
onClose
|
||||
}) => {
|
||||
const [form, setForm] = React.useState<IFormState>({
|
||||
companyId: source.companyId ?? '',
|
||||
departmentId: source.departmentId ?? '',
|
||||
byProject: source.byProject,
|
||||
requiresControl: source.requiresControl
|
||||
});
|
||||
const steps = useRouteSteps(api, source.id);
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
const departmentsForSelectedCompany = React.useMemo(
|
||||
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
|
||||
[departments, form.companyId]
|
||||
);
|
||||
|
||||
const sourceCompany = source.companyId
|
||||
? companies.find(c => c.id === source.companyId)?.name ?? shortGuid(source.companyId)
|
||||
: texts.routeSteps.anyCompany;
|
||||
const sourceDepartment = source.departmentId
|
||||
? departments.find(d => d.id === source.departmentId)?.name ?? shortGuid(source.departmentId)
|
||||
: texts.routeSteps.anyDepartment;
|
||||
|
||||
// Шаги с ролью в проекте бэкенд не примет в маршрут без признака «по проекту».
|
||||
const projectRoleSteps = steps.items.filter(s => s.projectRole && s.projectRole !== ProjectRole.None).length;
|
||||
const projectRoleBlocks = projectRoleSteps > 0 && !form.byProject;
|
||||
|
||||
// Копия должна отличаться от источника: тип заявки у них общий, значит
|
||||
// различие обязано быть в компании, отделе или признаке «по проекту».
|
||||
const sameAsSource =
|
||||
(form.companyId || '') === (source.companyId ?? '') &&
|
||||
(form.departmentId || '') === (source.departmentId ?? '') &&
|
||||
form.byProject === source.byProject;
|
||||
|
||||
const submitForm = (): void => {
|
||||
if (sameAsSource) {
|
||||
submit.fail(texts.routes.validationCopySame);
|
||||
return;
|
||||
}
|
||||
if (projectRoleBlocks) {
|
||||
submit.fail(texts.routes.copyProjectRoleWarning(projectRoleSteps));
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = form.companyId.trim() ? form.companyId.trim() : null;
|
||||
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
|
||||
|
||||
submit.run(async () => {
|
||||
const created = await api.templateRoutes.create({
|
||||
requestTypeId: source.requestTypeId,
|
||||
companyId,
|
||||
departmentId,
|
||||
byProject: form.byProject,
|
||||
requiresControl: form.requiresControl
|
||||
});
|
||||
|
||||
// Последовательно, а не Promise.all: при ошибке важно знать, на каком шаге
|
||||
// сорвались, а параллельные запросы к тому же дают гонку по orderNo в логах.
|
||||
try {
|
||||
for (const step of [...steps.items].sort((a, b) => a.orderNo - b.orderNo)) {
|
||||
await api.templateRouteSteps.create(created.id, toStepInput(step)).catch((e: ApiClientError) => {
|
||||
throw new Error(texts.routes.copyStepFailed(step.orderNo, e.message));
|
||||
});
|
||||
}
|
||||
} catch (stepError) {
|
||||
// Откат: маршрут без части шагов резолвился бы молча и неверно.
|
||||
try {
|
||||
await api.templateRoutes.remove(created.id);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`${(stepError as Error).message} ${texts.routes.copyRollbackFailed(created.id)}`
|
||||
);
|
||||
}
|
||||
throw new Error(`${(stepError as Error).message} ${texts.routes.copyRolledBack}`);
|
||||
}
|
||||
|
||||
onCopied();
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
title={texts.routes.copyTitle(source.id)}
|
||||
error={submit.error}
|
||||
isSaving={submit.isBusy}
|
||||
submitLabel={texts.routes.copyAction}
|
||||
savingLabel={texts.routes.copyInProgress}
|
||||
onSubmit={submitForm}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Alert kind="ok">{texts.routes.copySource(requestTypeName, sourceCompany, sourceDepartment)}</Alert>
|
||||
|
||||
{steps.error && <Alert kind="err">{steps.error}</Alert>}
|
||||
{!steps.error && steps.isLoading && <Alert kind="ok">{texts.routes.copyStepsLoading}</Alert>}
|
||||
{!steps.error && !steps.isLoading && (
|
||||
<Alert kind={steps.items.length > 0 ? 'ok' : 'warn'}>
|
||||
{steps.items.length > 0 ? texts.routes.copyStepsCount(steps.items.length) : texts.routes.copyStepsEmpty}
|
||||
</Alert>
|
||||
)}
|
||||
{projectRoleBlocks && <Alert kind="warn">{texts.routes.copyProjectRoleWarning(projectRoleSteps)}</Alert>}
|
||||
|
||||
<FormGrid>
|
||||
<Field label={texts.routes.companyLabel} full hint={texts.routes.copyHint}>
|
||||
<SearchableSelect
|
||||
options={companies.map(c => ({ value: c.id, label: c.name }))}
|
||||
value={form.companyId}
|
||||
loading={dictionaryLoading}
|
||||
allowClear
|
||||
clearLabel={texts.routes.companyClear}
|
||||
placeholder={texts.routes.companyPlaceholder}
|
||||
searchPlaceholder={texts.routes.companySearchPlaceholder}
|
||||
onChange={companyId =>
|
||||
// Как и в форме маршрута: смена компании обнуляет отдел — прежний
|
||||
// принадлежал другой компании.
|
||||
setForm({ ...form, companyId, departmentId: '' })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={texts.routes.departmentLabel}
|
||||
full
|
||||
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
|
||||
>
|
||||
<SearchableSelect
|
||||
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
|
||||
value={form.departmentId}
|
||||
loading={dictionaryLoading}
|
||||
disabled={!form.companyId}
|
||||
allowClear
|
||||
clearLabel={texts.routes.departmentPlaceholder}
|
||||
placeholder={texts.routes.departmentPlaceholder}
|
||||
searchPlaceholder={texts.routes.departmentSearchPlaceholder}
|
||||
emptyMessage={texts.routes.departmentEmpty}
|
||||
onChange={departmentId => setForm({ ...form, departmentId })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routes.byProjectLabel}>
|
||||
<Checkbox
|
||||
label={texts.routes.byProjectCheckbox}
|
||||
checked={form.byProject}
|
||||
onChange={v => setForm({ ...form, byProject: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routes.controlLabel}>
|
||||
<Checkbox
|
||||
label={texts.routes.controlCheckbox}
|
||||
checked={form.requiresControl}
|
||||
onChange={v => setForm({ ...form, requiresControl: v })}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри
|
||||
// Field, поэтому отступ сверху небольшой — визуально это продолжение поля,
|
||||
// а не отдельный блок.
|
||||
.positionPreview {
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
background: $gray-50;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// Найдено — зелёная рамка: должность рабочая, шаг резолвится.
|
||||
.previewOk {
|
||||
border-color: $green-600;
|
||||
background: rgba($green-600, .06);
|
||||
}
|
||||
|
||||
// Не найдено — предупреждение: шаг останется без исполнителя.
|
||||
.previewMiss {
|
||||
border-color: $gold-500;
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.previewList {
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.previewPerson {
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.previewCompany {
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// Похожая должность — кнопка: клик подставляет её точное написание в поле,
|
||||
// чтобы не переписывать вручную и не ошибиться в регистре/пробелах.
|
||||
.previewSuggest {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-family: $font-sans;
|
||||
font-size: 12px;
|
||||
color: $navy-700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.previewSuggestCount {
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RouteStepFormModal.module.scss';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
Employee,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from '../../models';
|
||||
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
@@ -31,6 +32,15 @@ import {
|
||||
export interface IRouteStepFormModalProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
/**
|
||||
* Название компании маршрута, если она задана (route.companyId) — undefined
|
||||
* для запасного маршрута («любая компания»). Резолв шага «по должности»
|
||||
* ищет исполнителя ИМЕННО в этой компании (initiatorCompany = компания
|
||||
* инициатора, а маршрут с заданной компанией применяется только к таким
|
||||
* инициаторам), поэтому подсказка должности должна сужаться под неё же —
|
||||
* иначе показала бы человека, которого этот маршрут никогда не найдёт.
|
||||
*/
|
||||
routeCompanyName?: string;
|
||||
/** Undefined, пока грузится тип заявки маршрута. */
|
||||
requestType?: RequestType;
|
||||
sharePoint: SharePointListsClient;
|
||||
@@ -76,6 +86,20 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
|
||||
.filter(v => v !== ComparisonOperator.None)
|
||||
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
|
||||
|
||||
/**
|
||||
* Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде
|
||||
* (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ
|
||||
* («Должность» — например, у увольняемого в обходном листе), а не по должности того,
|
||||
* кто отправил заявку. Поэтому поле доступно всегда, даже если у типа заявки не
|
||||
* настроен list_name — источник данных не сам список, а fieldValues resolve-запроса.
|
||||
*/
|
||||
const IS_MANAGER_FIELD = 'IsManager';
|
||||
const SYNTHETIC_FIELD_OPTIONS = [{ value: IS_MANAGER_FIELD, label: texts.routeSteps.conditionFieldIsManagerLabel }];
|
||||
const IS_MANAGER_VALUE_OPTIONS = [
|
||||
{ value: 'true', label: texts.common.yes },
|
||||
{ value: 'false', label: texts.common.no }
|
||||
];
|
||||
|
||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||
if (!editing) {
|
||||
return {
|
||||
@@ -117,6 +141,7 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
||||
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
api,
|
||||
route,
|
||||
routeCompanyName,
|
||||
requestType,
|
||||
sharePoint,
|
||||
editing,
|
||||
@@ -132,18 +157,23 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||
|
||||
const fieldNameOptions = React.useMemo(() => {
|
||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions];
|
||||
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
|
||||
options.unshift({ value: form.fieldName, label: form.fieldName });
|
||||
options.push({ value: form.fieldName, label: form.fieldName });
|
||||
}
|
||||
return options;
|
||||
}, [sharePointFields.items, form.fieldName]);
|
||||
|
||||
const isManagerField = form.fieldName === IS_MANAGER_FIELD;
|
||||
|
||||
// Тип столбца известен, только если он есть среди подгруженных полей списка —
|
||||
// для значения, введённого до появления поля в списке (или после его удаления),
|
||||
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
|
||||
// IsManager — служебное булево поле, а не колонка списка, поэтому для него
|
||||
// числовые ограничения не действуют в любом случае.
|
||||
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
|
||||
const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
|
||||
const isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
|
||||
|
||||
const operationOptions = isNumericField
|
||||
? OPERATION_OPTIONS
|
||||
@@ -158,6 +188,121 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNumericField]);
|
||||
|
||||
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
|
||||
// а не произвольным текстом, который пользователь мог набрать для другого field_name.
|
||||
React.useEffect(() => {
|
||||
if (isManagerField && form.value !== 'true' && form.value !== 'false') {
|
||||
setForm(f => ({ ...f, value: 'true' }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isManagerField]);
|
||||
|
||||
// Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ
|
||||
// совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо
|
||||
// оставляет шаг без исполнителя — до самого резолва об этом узнать негде.
|
||||
// Считаем по уже загруженному справочнику сотрудников, без запросов на ввод.
|
||||
const positionMatches = React.useMemo(
|
||||
() => matchEmployeesByPosition(employees, form.position),
|
||||
[employees, form.position]
|
||||
);
|
||||
|
||||
const positionPreview = ((): React.ReactNode => {
|
||||
if (!form.position.trim()) return undefined;
|
||||
if (employeesLoading) {
|
||||
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewLoading}</div>;
|
||||
}
|
||||
// Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это
|
||||
// сказало бы неправду про корректную должность.
|
||||
if (employees.length === 0) {
|
||||
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewNoDictionary}</div>;
|
||||
}
|
||||
|
||||
if (positionMatches.exact.length > 0) {
|
||||
// У маршрута задана конкретная компания — резолв ищет исполнителя ИМЕННО
|
||||
// в ней (route.companyId сужает круг инициаторов до этой компании, а
|
||||
// бэкенд ищет по компании инициатора). Найденные в других компаниях
|
||||
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
|
||||
const inRouteCompany = routeCompanyName
|
||||
? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1)
|
||||
: positionMatches.exact;
|
||||
|
||||
if (inRouteCompany.length > 0) {
|
||||
return (
|
||||
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
|
||||
<span className={styles.previewTitle}>
|
||||
{routeCompanyName
|
||||
? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length)
|
||||
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
|
||||
</span>
|
||||
<ul className={styles.previewList}>
|
||||
{inRouteCompany.map(e => {
|
||||
// Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
|
||||
// маршрутом — она не обязана быть его company (первой из
|
||||
// справочника), если он числится в нескольких (см. companies).
|
||||
const shownCompany = routeCompanyName ?? e.company;
|
||||
return (
|
||||
<li key={e.email}>
|
||||
<span className={styles.previewPerson}>{e.fio}</span>
|
||||
{shownCompany && <span className={styles.previewCompany}> · {shownCompany}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// routeCompanyName задан, но среди найденных нет никого из этой компании —
|
||||
// должность существует, но не там, где её будет искать этот маршрут.
|
||||
return (
|
||||
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
|
||||
<span className={styles.previewTitle}>
|
||||
{texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)}
|
||||
</span>
|
||||
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewOtherCompanies}</div>
|
||||
<ul className={styles.previewList}>
|
||||
{positionMatches.exact.map(e => (
|
||||
<li key={e.email}>
|
||||
<span className={styles.previewPerson}>{e.fio}</span>
|
||||
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
|
||||
<span className={styles.previewTitle}>{texts.routeSteps.positionPreviewNone}</span>
|
||||
{positionMatches.similar.length > 0 && (
|
||||
<>
|
||||
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewSimilar}</div>
|
||||
<ul className={styles.previewList}>
|
||||
{positionMatches.similar.map(s => (
|
||||
<li key={s.position}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.previewSuggest}
|
||||
onClick={() => setForm(f => ({ ...f, position: s.position }))}
|
||||
>
|
||||
{s.position} — {s.fio}
|
||||
{s.count > 1 && (
|
||||
<span className={styles.previewSuggestCount}>
|
||||
{' '}
|
||||
({texts.routeSteps.positionPreviewMore(s.count - 1)})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
const submitForm = (): void => {
|
||||
const orderNo = Number(form.orderNo);
|
||||
if (!orderNo || orderNo < 1) {
|
||||
@@ -276,6 +421,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
onChange={e => setForm({ ...form, position: e.target.value })}
|
||||
placeholder={texts.routeSteps.positionPlaceholder}
|
||||
/>
|
||||
{positionPreview}
|
||||
</Field>
|
||||
)}
|
||||
{form.kind === 'projectRole' && (
|
||||
@@ -326,12 +472,11 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
<Checkbox
|
||||
label={texts.routeSteps.conditionCheckbox}
|
||||
checked={form.hasCondition}
|
||||
disabled={!hasListId}
|
||||
onChange={v => setForm({ ...form, hasCondition: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.hasCondition && hasListId && (
|
||||
{form.hasCondition && (
|
||||
<>
|
||||
<Field
|
||||
label={texts.routeSteps.conditionFieldNameLabel}
|
||||
@@ -361,12 +506,20 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
{isManagerField ? (
|
||||
<Select
|
||||
options={IS_MANAGER_VALUE_OPTIONS}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
type={isNumericField ? 'number' : 'text'}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
placeholder={texts.routeSteps.conditionValuePlaceholder}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Таблица шагов маршрута собрана вручную (а не через DataTable) из-за rowSpan:
|
||||
// колонка «№» и стрелки перестановки объединяются на весь блок параллельных
|
||||
// шагов. Стили повторяют ManageRequestsScreen.module.scss — там та же механика
|
||||
// для строк заявки (см. ManageRequestsPhaseTable).
|
||||
|
||||
.stepCol {
|
||||
width: 56px;
|
||||
white-space: nowrap;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
// Колонка «№» закреплена слева при горизонтальном скролле — на узких экранах/
|
||||
// в редакторе SharePoint таблица шире контейнера, и без этого номер шага
|
||||
// (единственный ориентир при длинном горизонтальном скролле) уезжал за край
|
||||
// вместе с остальными колонками. Зеркало правого .stickyCell в
|
||||
// DataTable.module.scss (там — action-колонка у правого края).
|
||||
.stepColSticky {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
box-shadow: 6px 0 6px -6px rgba(15, 36, 64, .12);
|
||||
}
|
||||
|
||||
.table thead th.stepColSticky {
|
||||
background: $gray-50;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table tbody td.stepColSticky {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// Явно, а не полагаясь на специфичность: .rowHover td и .stepColSticky иначе
|
||||
// шли бы вровень, и было бы неочевидно, какой фон победит при следующей правке.
|
||||
.rowHover td.stepColSticky {
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
// Шаг, идущий одновременно с соседним (общий orderNo) — помечаем знаком ∥,
|
||||
// иначе два одинаковых номера подряд выглядят как ошибка данных.
|
||||
.parallelMark {
|
||||
color: $gold-600;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flipY {
|
||||
svg { transform: scaleY(-1); }
|
||||
}
|
||||
|
||||
// Штатную подсветку строки отключаем: ячейки с rowSpan принадлежат <tr> только
|
||||
// первой строки блока, поэтому CSS :hover оставлял «вырез» в подсветке на
|
||||
// границе объединённой ячейки. Подсвечиваем блок целиком через React-состояние.
|
||||
.blockAwareWrap :global(tbody tr:hover) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rowHover td {
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
// IconButton по умолчанию прозрачная (рамка только по hover). Здесь кнопок
|
||||
// много и часть неочевидна по смыслу (параллельность, перестановка) — рамка
|
||||
// нужна всегда, иначе выглядит как текст. !important: это разные CSS-модули,
|
||||
// порядок их правил в бандле не гарантирован.
|
||||
.iconBtnVisible {
|
||||
border-color: $gray-300 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
// «Объединить с предыдущим» — переключатель состояния, а не разовое действие,
|
||||
// поэтому включённое состояние залито.
|
||||
.iconBtnActive {
|
||||
border-color: $gold-500 !important;
|
||||
background: $gold-500 !important;
|
||||
color: $navy-950 !important;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-600 !important;
|
||||
background: $gold-600 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import tableStyles from '../ui/DataTable.module.scss';
|
||||
import styles from './RouteStepsScreen.module.scss';
|
||||
import { RouteStepFormModal } from './RouteStepFormModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { SharePointListsClient } from '../../services/sharePointLists';
|
||||
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
|
||||
import { routeStepStatusLabels, texts } from '../../texts';
|
||||
import { shortGuid } from '../../utils/guid';
|
||||
import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
|
||||
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
@@ -17,7 +23,6 @@ import {
|
||||
Button,
|
||||
CellNote,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell
|
||||
@@ -34,10 +39,17 @@ export interface IRouteStepsScreenProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/** Шаг + признак «идёт одновременно с предыдущим» (выводится из равного orderNo). */
|
||||
type Row = TemplateRouteStep & { order: number; parallelWithPrev?: boolean };
|
||||
|
||||
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
|
||||
const steps = useRouteSteps(api, route.id);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
|
||||
const [reorderError, setReorderError] = React.useState<string | undefined>();
|
||||
const [reordering, setReordering] = React.useState(false);
|
||||
const [hoveredBlock, setHoveredBlock] = React.useState<number | undefined>(undefined);
|
||||
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||
@@ -48,6 +60,15 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
steps.reload
|
||||
);
|
||||
|
||||
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
|
||||
// показываем усечённый Guid — как в RoutesScreen.
|
||||
const companyName = route.companyId
|
||||
? portalDictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId)
|
||||
: texts.routeSteps.anyCompany;
|
||||
const departmentName = route.departmentId
|
||||
? portalDictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId)
|
||||
: texts.routeSteps.anyDepartment;
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setShowForm(true);
|
||||
@@ -63,6 +84,56 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
steps.reload();
|
||||
};
|
||||
|
||||
// Параллельность у шагов шаблона кодируется тем же способом, что и у строк
|
||||
// заявки: равный порядковый номер у соседей (отдельного флага в БД нет —
|
||||
// см. utils/orderBlocks.ts и TemplateRouteStep.orderNo). markParallel/blockRanges
|
||||
// работают по полю order, поэтому orderNo продублирован в него.
|
||||
const withParallel = React.useMemo(
|
||||
() => markParallel(steps.items.map(s => ({ ...s, order: s.orderNo }))) as Row[],
|
||||
[steps.items]
|
||||
);
|
||||
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
|
||||
|
||||
// Перестановка = пересчёт orderNo у затронутых шагов. Патчим только те, у кого
|
||||
// номер реально изменился: PATCH с одним orderNo сохраняет остальные поля
|
||||
// (см. PatchAsync на бэкенде — там `r.X ?? entity.X`).
|
||||
const applyReorder = (mutate: (rows: Row[]) => Row[]): void => {
|
||||
const renumbered = withOrdersFrom(mutate(withParallel), 0) as Row[];
|
||||
const updates = renumbered.filter(r => r.orderNo !== r.order).map(r => ({ id: r.id, orderNo: r.order }));
|
||||
if (updates.length === 0) return;
|
||||
|
||||
setReorderError(undefined);
|
||||
setReordering(true);
|
||||
Promise.all(updates.map(u => api.templateRouteSteps.patch(route.id, u.id, { orderNo: u.orderNo })))
|
||||
.then(() => steps.reload())
|
||||
.catch((e: Error) => setReorderError(texts.routeSteps.reorderFailed(reportError(e, 'RouteStepsScreen: reorder'))))
|
||||
.then(() => setReordering(false), () => setReordering(false));
|
||||
};
|
||||
|
||||
const moveBlockUp = (blockIndex: number): void => {
|
||||
if (blockIndex <= 0) return;
|
||||
applyReorder(current => {
|
||||
const a = blocks[blockIndex - 1];
|
||||
const b = blocks[blockIndex];
|
||||
return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)];
|
||||
});
|
||||
};
|
||||
|
||||
const moveBlockDown = (blockIndex: number): void => {
|
||||
if (blockIndex >= blocks.length - 1) return;
|
||||
applyReorder(current => {
|
||||
const a = blocks[blockIndex];
|
||||
const b = blocks[blockIndex + 1];
|
||||
return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)];
|
||||
});
|
||||
};
|
||||
|
||||
// Первая строка списка объединяться не с чем — у неё переключатель недоступен.
|
||||
const toggleParallel = (rowIndex: number): void => {
|
||||
if (rowIndex <= 0) return;
|
||||
applyReorder(current => current.map((r, i) => (i === rowIndex ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r)));
|
||||
};
|
||||
|
||||
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
|
||||
|
||||
return (
|
||||
@@ -76,7 +147,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
/>
|
||||
<PageHeader
|
||||
icon="flag-check"
|
||||
subtitle={texts.routeSteps.subtitle(requestTypeName)}
|
||||
subtitle={texts.routeSteps.subtitle(requestTypeName, companyName, departmentName)}
|
||||
title={texts.routeSteps.title(route.id)}
|
||||
actions={
|
||||
<>
|
||||
@@ -88,65 +159,131 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
||||
|
||||
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
|
||||
{steps.error && <Alert kind="err">{steps.error}</Alert>}
|
||||
{reorderError && <Alert kind="err">{reorderError}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
|
||||
{
|
||||
key: 'executor',
|
||||
header: texts.routeSteps.columnExecutor,
|
||||
render: r => {
|
||||
const executor = describeStepExecutor(r, employees.items);
|
||||
return (
|
||||
<div>
|
||||
<PrimaryCell>{executor.primary}</PrimaryCell>
|
||||
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: texts.routeSteps.columnStatus,
|
||||
render: r => (
|
||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
|
||||
{routeStepStatusLabels[r.status]}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'reassign',
|
||||
header: texts.routeSteps.columnReassign,
|
||||
align: 'center',
|
||||
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'condition',
|
||||
header: texts.routeSteps.columnCondition,
|
||||
render: r => describeStepCondition(r) ?? texts.common.empty
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={steps.items}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
|
||||
/>
|
||||
{steps.items.length === 0 && (
|
||||
<div className={tableStyles.wrap}>
|
||||
<div className={tableStyles.empty}>{steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{steps.items.length > 0 && (
|
||||
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
|
||||
<div className={tableStyles.scroll}>
|
||||
<table className={tableStyles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${styles.stepCol} ${styles.stepColSticky}`}>{texts.routeSteps.columnOrderNo}</th>
|
||||
<th>{texts.routeSteps.columnExecutor}</th>
|
||||
<th>{texts.routeSteps.columnStatus}</th>
|
||||
<th style={{ textAlign: 'center' }}>{texts.routeSteps.columnReassign}</th>
|
||||
<th>{texts.routeSteps.columnCondition}</th>
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{withParallel.map((r, i) => {
|
||||
const blockIndex = blockIndexOf(blocks, i);
|
||||
const block = blocks[blockIndex];
|
||||
const isBlockStart = block.start === i;
|
||||
const executor = describeStepExecutor(r, employees.items);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={blockIndex === hoveredBlock ? styles.rowHover : undefined}
|
||||
onMouseEnter={() => setHoveredBlock(blockIndex)}
|
||||
onMouseLeave={() => setHoveredBlock(undefined)}
|
||||
>
|
||||
{isBlockStart && (
|
||||
<td className={`${styles.stepCol} ${styles.stepColSticky}`} rowSpan={block.end - block.start + 1}>
|
||||
{block.end > block.start ? (
|
||||
<span className={styles.parallelMark} title={texts.routeSteps.mergeWithPrev}>
|
||||
{r.order} ∥
|
||||
</span>
|
||||
) : (
|
||||
r.order
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<PrimaryCell>{executor.primary}</PrimaryCell>
|
||||
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
|
||||
{routeStepStatusLabels[r.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{r.allowReassignToOtherApprovers
|
||||
? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge>
|
||||
: <Badge variant="neutral">{texts.common.no}</Badge>}
|
||||
</td>
|
||||
<td>{describeStepCondition(r) ?? texts.common.empty}</td>
|
||||
{/* Стрелки — одна пара на весь блок (rowSpan): двигают шаг
|
||||
целиком, а не отдельного участника параллельной группы. */}
|
||||
{isBlockStart && (
|
||||
<td rowSpan={block.end - block.start + 1}>
|
||||
<ActionsCell>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
className={`${styles.iconBtnVisible} ${styles.flipY}`}
|
||||
label={texts.routeSteps.moveUp}
|
||||
disabled={reordering || blockIndex <= 0}
|
||||
onClick={() => moveBlockUp(blockIndex)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
className={styles.iconBtnVisible}
|
||||
label={texts.routeSteps.moveDown}
|
||||
disabled={reordering || blockIndex >= blocks.length - 1}
|
||||
onClick={() => moveBlockDown(blockIndex)}
|
||||
/>
|
||||
</ActionsCell>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<ActionsCell>
|
||||
<IconButton
|
||||
icon="people"
|
||||
className={r.parallelWithPrev ? styles.iconBtnActive : styles.iconBtnVisible}
|
||||
label={r.parallelWithPrev ? texts.routeSteps.splitFromPrev : texts.routeSteps.mergeWithPrev}
|
||||
disabled={reordering || i === 0}
|
||||
onClick={() => toggleParallel(i)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="edit"
|
||||
className={styles.iconBtnVisible}
|
||||
label={texts.common.edit}
|
||||
onClick={() => openEdit(r)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
className={styles.iconBtnVisible}
|
||||
label={texts.common.remove}
|
||||
danger
|
||||
onClick={() => deleteTarget.ask(r)}
|
||||
/>
|
||||
</ActionsCell>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<RouteStepFormModal
|
||||
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
||||
// применяется к любой компании инициатора) — тогда подсказка
|
||||
// должности показывает всех, как раньше, а не сужает по компании.
|
||||
routeCompanyName={route.companyId ? companyName : undefined}
|
||||
api={api}
|
||||
route={route}
|
||||
requestType={requestType}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RoutesScreen.module.scss';
|
||||
import { RouteFormModal } from './RouteFormModal';
|
||||
import { RouteCopyModal } from './RouteCopyModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { TemplateRoute } from '../../models';
|
||||
@@ -10,6 +11,8 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { replaceRoute } from '../../routing';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
@@ -28,18 +31,46 @@ import {
|
||||
export interface IRoutesScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
dictionary?: PortalDictionaryClient;
|
||||
/** Фильтр из ссылки (aarfilter) — перекрывает сохранённый, если задан. */
|
||||
initialFilterRequestTypeId?: string;
|
||||
onOpenSteps: (route: TemplateRoute) => void;
|
||||
}
|
||||
|
||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
||||
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
||||
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
||||
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
||||
|
||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||
api,
|
||||
dictionary,
|
||||
initialFilterRequestTypeId,
|
||||
onOpenSteps
|
||||
}) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState('');
|
||||
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ROUTES_FILTER_STORAGE_KEY, '');
|
||||
|
||||
// Открыли по ссылке с фильтром — она сильнее сохранённого значения. Только на
|
||||
// монтировании: дальше источник истины — сам экран (он же пишет фильтр в URL
|
||||
// ниже), иначе собственный replaceRoute тут же вернулся бы сюда как «внешний».
|
||||
React.useEffect(() => {
|
||||
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Держим URL синхронным с фильтром — ссылку на отфильтрованный список можно
|
||||
// скопировать. replaceRoute, а не navigate: смена фильтра не должна плодить
|
||||
// записи в истории браузера.
|
||||
React.useEffect(() => {
|
||||
replaceRoute({ tab: 'routes', routesFilter: filterRequestTypeId });
|
||||
}, [filterRequestTypeId]);
|
||||
|
||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
||||
|
||||
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||
|
||||
@@ -87,6 +118,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'id', header: texts.routes.columnId, width: '90px', render: r => `#${r.id}` },
|
||||
{
|
||||
key: 'requestType',
|
||||
header: texts.routes.columnRequestType,
|
||||
@@ -115,6 +147,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
|
||||
<IconButton icon="copy" label={texts.routes.copy} onClick={() => setCopying(r)} />
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
@@ -140,6 +173,19 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
|
||||
/>
|
||||
)}
|
||||
|
||||
{copying && (
|
||||
<RouteCopyModal
|
||||
api={api}
|
||||
source={copying}
|
||||
requestTypeName={requestTypeName(copying.requestTypeId)}
|
||||
companies={portalDictionaries.companies}
|
||||
departments={portalDictionaries.departments}
|
||||
dictionaryLoading={portalDictionaries.isLoading}
|
||||
onCopied={routes.reload}
|
||||
onClose={() => setCopying(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title={texts.routes.deleteTitle}
|
||||
|
||||
@@ -18,10 +18,17 @@
|
||||
.full { grid-column: 1 / -1; }
|
||||
|
||||
.label {
|
||||
// display:block + min-height на 2 строки — так подпись из одной строки
|
||||
// и подпись, свернувшаяся на две (например «Спрашивать причину
|
||||
// просрочки»), одинаково резервируют место, и контролы двух полей одной
|
||||
// строки грида начинаются на одном уровне независимо от переноса текста.
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $gray-700;
|
||||
letter-spacing: .2px;
|
||||
line-height: 16px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.req { color: $gold-600; }
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface IFormModalProps {
|
||||
/** Текст ошибки: и валидации формы, и ответа бэкенда. */
|
||||
error?: string;
|
||||
isSaving: boolean;
|
||||
/** Подпись кнопки подтверждения — по умолчанию «Сохранить» (см. копирование маршрута). */
|
||||
submitLabel?: string;
|
||||
/** Подпись той же кнопки во время запроса — по умолчанию «Сохранение...». */
|
||||
savingLabel?: string;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
@@ -18,7 +22,16 @@ export interface IFormModalProps {
|
||||
* Модальное окно формы: одинаковая обвязка «ошибка сверху, Отмена/Сохранить снизу»
|
||||
* для всех справочников, чтобы экраны описывали только поля.
|
||||
*/
|
||||
export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, onSubmit, onClose, children }) => (
|
||||
export const FormModal: React.FC<IFormModalProps> = ({
|
||||
title,
|
||||
error,
|
||||
isSaving,
|
||||
submitLabel = texts.common.save,
|
||||
savingLabel = texts.common.saving,
|
||||
onSubmit,
|
||||
onClose,
|
||||
children
|
||||
}) => (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
@@ -26,7 +39,7 @@ export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, o
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
|
||||
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
||||
{isSaving ? texts.common.saving : texts.common.save}
|
||||
{isSaving ? savingLabel : submitLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ export type IconName =
|
||||
| 'briefcase'
|
||||
| 'flag'
|
||||
| 'crown'
|
||||
| 'refresh';
|
||||
| 'refresh'
|
||||
| 'copy';
|
||||
|
||||
let spriteInjected = false;
|
||||
|
||||
@@ -51,6 +52,7 @@ const SPRITE_SVG = `
|
||||
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 3v18"/><path d="M5 4h13l-3 4 3 4H5"/></symbol>
|
||||
<symbol id="i-crown" viewBox="0 0 24 24"><path d="m3 8 4 3 5-6 5 6 4-3-2 11H5z"/></symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></symbol>
|
||||
`;
|
||||
|
||||
function ensureSpriteInjected(): void {
|
||||
|
||||
@@ -33,4 +33,18 @@ export interface Employee {
|
||||
* подсказка при выборе сотрудника (см. ManageRequestsApproverModal), не для
|
||||
* отображения в списке (сам справочник должностей не показывает). */
|
||||
position: string;
|
||||
/**
|
||||
* Первая компания сотрудника из справочника ('' если не пришла) — для
|
||||
* отображения в пикере/списках, где на человека нужна ровно одна строка.
|
||||
*/
|
||||
company: string;
|
||||
/**
|
||||
* ВСЕ компании, где числится сотрудник — совместители и штат общих служб
|
||||
* (бухгалтерия, HR) на несколько юрлиц не редкость: в api.tnsu.kz это
|
||||
* отдельная строка на каждую компанию, а не одна с списком. Проверять
|
||||
* принадлежность к конкретной компании (маршрут ограничен ею) нужно по
|
||||
* этому полю, а не по company — иначе сотрудник, у которого в справочнике
|
||||
* первой оказалась ДРУГАЯ его компания, ошибочно выглядел бы отсутствующим.
|
||||
*/
|
||||
companies: string[];
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export enum HeadType {
|
||||
Chief = 'Chief',
|
||||
DirectionHead = 'DirectionHead',
|
||||
DirectorTansu = 'DirectorTansu',
|
||||
ManagingDirectorTansu = 'ManagingDirectorTansu',
|
||||
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
|
||||
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
|
||||
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
|
||||
|
||||
@@ -37,6 +37,19 @@ describe('parseRoute', () => {
|
||||
expect(parseRoute('?aatab=routes').routeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aarfilter учитывается только при tab=routes', () => {
|
||||
expect(parseRoute('?aatab=routes&aarfilter=11').routesFilter).toBe('11');
|
||||
expect(parseRoute('?aatab=manageRequests&aarfilter=11').routesFilter).toBeUndefined();
|
||||
});
|
||||
|
||||
// Фильтр живёт и при открытых шагах: возврат «к маршрутам» должен вернуть
|
||||
// к тому же отфильтрованному списку (см. AdminApp.onBack).
|
||||
it('aarfilter читается вместе с aaroute', () => {
|
||||
const route = parseRoute('?aatab=routes&aaroute=7&aarfilter=11');
|
||||
expect(route.routeId).toBe(7);
|
||||
expect(route.routesFilter).toBe('11');
|
||||
});
|
||||
|
||||
it('resolve.fieldValues парсит валидный JSON-массив', () => {
|
||||
const fields = encodeURIComponent(JSON.stringify([{ fieldName: 'Amount', value: '100' }]));
|
||||
const route = parseRoute(`?aatab=resolve&aartype=1&aafields=${fields}`);
|
||||
|
||||
@@ -43,6 +43,12 @@ export interface IAdminRoute {
|
||||
/** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута,
|
||||
* минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */
|
||||
routeId?: number;
|
||||
/**
|
||||
* Фильтр списка маршрутов по типу заявки (id как строка, '' — все типы).
|
||||
* Живёт и при открытых шагах (routeId): возврат «к маршрутам» должен вернуть
|
||||
* к тому же отфильтрованному списку, а не сбросить фильтр.
|
||||
*/
|
||||
routesFilter?: string;
|
||||
/** Задан только при tab='resolve' и непустом requestTypeId. */
|
||||
resolve?: IResolveRouteParams;
|
||||
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
||||
@@ -56,7 +62,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
||||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
|
||||
|
||||
const PARAM_KEYS = [
|
||||
'aatab', 'aaroute',
|
||||
'aatab', 'aaroute', 'aarfilter',
|
||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||
'aamtype', 'aamreq'
|
||||
];
|
||||
@@ -114,6 +120,7 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
||||
return {
|
||||
tab,
|
||||
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
|
||||
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
|
||||
resolve,
|
||||
manage
|
||||
};
|
||||
@@ -127,8 +134,9 @@ function buildQuery(route: IAdminRoute): string {
|
||||
|
||||
if (route.tab !== 'dashboard') params.set('aatab', route.tab);
|
||||
|
||||
if (route.tab === 'routes' && route.routeId) {
|
||||
params.set('aaroute', String(route.routeId));
|
||||
if (route.tab === 'routes') {
|
||||
if (route.routeId) params.set('aaroute', String(route.routeId));
|
||||
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
|
||||
}
|
||||
|
||||
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
|
||||
|
||||
@@ -116,45 +116,3 @@ export class AadApiHttpClient implements IHttpClient {
|
||||
.then((r: HttpClientResponse) => parseResponse<T>(r));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная отладка: обычный fetch с заголовком X-Dev-User-Email.
|
||||
* Работает только если бэкенд поднят с пустыми Entra:TenantId/Entra:Audience
|
||||
* (см. backend/.../Auth/AuthSetup.cs — тогда включается DevAuthHandler).
|
||||
*/
|
||||
export class DevApiHttpClient implements IHttpClient {
|
||||
constructor(private readonly baseUrl: string, private readonly devUserEmail: string) {}
|
||||
|
||||
public get<T>(path: string, query?: QueryParams): Promise<T> {
|
||||
return this.send<T>('GET', path, undefined, query);
|
||||
}
|
||||
|
||||
public post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.send<T>('POST', path, body);
|
||||
}
|
||||
|
||||
public put<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.send<T>('PUT', path, body);
|
||||
}
|
||||
|
||||
public patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.send<T>('PATCH', path, body);
|
||||
}
|
||||
|
||||
public async delete(path: string): Promise<void> {
|
||||
await this.send<void>('DELETE', path);
|
||||
}
|
||||
|
||||
private async send<T>(method: string, path: string, body?: unknown, query?: QueryParams): Promise<T> {
|
||||
const url = buildUrl(this.baseUrl, path, query);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Dev-User-Email': this.devUserEmail
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
});
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ interface IPortalEmployee {
|
||||
fio: string;
|
||||
email: string | null;
|
||||
position: string | null;
|
||||
company: string | null;
|
||||
}
|
||||
|
||||
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
||||
@@ -112,11 +113,21 @@ export class PortalDictionaryClient {
|
||||
if (!x.email) return;
|
||||
const existing = byEmail.get(x.email);
|
||||
if (!existing) {
|
||||
byEmail.set(x.email, { fio: x.fio, email: x.email, position: x.position || '' });
|
||||
} else if (!existing.position && x.position) {
|
||||
// Один сотрудник — строка на каждый его отдел; должность могла найтись
|
||||
// не в первой из них.
|
||||
existing.position = x.position;
|
||||
byEmail.set(x.email, {
|
||||
fio: x.fio,
|
||||
email: x.email,
|
||||
position: x.position || '',
|
||||
company: x.company || '',
|
||||
// Одна строка на каждую компанию, где числится сотрудник (совместители/
|
||||
// бухгалтерия на несколько юрлиц — не редкость), companies собирает их
|
||||
// все, а company остаётся первой встреченной (для пикера/отображения,
|
||||
// где нужна ровно одна строка на человека).
|
||||
companies: x.company ? [x.company] : []
|
||||
});
|
||||
} else {
|
||||
if (!existing.position && x.position) existing.position = x.position;
|
||||
if (!existing.company && x.company) existing.company = x.company;
|
||||
if (x.company && existing.companies.indexOf(x.company) === -1) existing.companies.push(x.company);
|
||||
}
|
||||
});
|
||||
return Array.from(byEmail.values());
|
||||
|
||||
@@ -31,6 +31,7 @@ export const headTypeLabels: Record<HeadType, string> = {
|
||||
[HeadType.Chief]: 'Непосредственный руководитель',
|
||||
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||
[HeadType.DirectorTansu]: 'Директор Tansu',
|
||||
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
|
||||
[HeadType.DepartmentHead]: 'Руководитель указанного отдела'
|
||||
};
|
||||
|
||||
@@ -158,6 +159,7 @@ export const texts = {
|
||||
filterAll: 'Все типы заявок',
|
||||
dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`,
|
||||
emptyList: 'Маршрутов пока нет.',
|
||||
columnId: '№ маршрута',
|
||||
columnRequestType: 'Тип заявки',
|
||||
columnCompany: 'Компания',
|
||||
columnDepartment: 'Отдел',
|
||||
@@ -185,6 +187,38 @@ export const texts = {
|
||||
controlLabel: 'Контроль',
|
||||
controlCheckbox: 'Требуется отдельный контроль исполнения',
|
||||
validationRequestType: 'Выберите тип заявки.',
|
||||
|
||||
// ---- Копирование маршрута ----
|
||||
copy: 'Копировать',
|
||||
copyTitle: (routeId: number): string => `Копия маршрута #${routeId}`,
|
||||
copyAction: 'Создать копию',
|
||||
copyInProgress: 'Копирование...',
|
||||
copyHint:
|
||||
'Копия создаётся для того же типа заявки вместе со всеми шагами. Маршрут различается по компании, ' +
|
||||
'отделу и признаку «по проекту» — измените хотя бы одно из них, иначе копия совпадёт с исходным маршрутом.',
|
||||
copySource: (requestTypeName: string, company: string, department: string): string =>
|
||||
`Копируем: ${requestTypeName} · ${company} · ${department}`,
|
||||
copyStepsCount: (count: number): string =>
|
||||
`Шагов будет скопировано: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])}`,
|
||||
copyStepsLoading: 'Загрузка шагов исходного маршрута...',
|
||||
copyStepsEmpty: 'В исходном маршруте нет шагов — копия будет пустой.',
|
||||
validationCopySame:
|
||||
'Копия совпадает с исходным маршрутом. Измените компанию, отдел или признак «по проекту» — ' +
|
||||
'иначе такой маршрут уже существует.',
|
||||
// Роль в проекте допустима только у маршрута с признаком «по проекту» (проверка
|
||||
// на бэкенде, TemplateRouteStepService) — предупреждаем ДО копирования, иначе
|
||||
// копия сорвётся на середине переноса шагов.
|
||||
copyProjectRoleWarning: (count: number): string =>
|
||||
`В исходном маршруте ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} с ролью в проекте. ` +
|
||||
'Без признака «по проекту» такие шаги скопировать нельзя — включите его или удалите эти шаги в исходном маршруте.',
|
||||
copyStepFailed: (orderNo: number, message: string): string =>
|
||||
`Не удалось скопировать шаг ${orderNo}: ${message}`,
|
||||
// Частично скопированный маршрут опаснее, чем несозданный: он бы резолвился
|
||||
// с недостающими согласующими. Поэтому при ошибке копию удаляем целиком.
|
||||
copyRolledBack: 'Копия не создана — маршрут удалён, чтобы не остался наполовину скопированным.',
|
||||
copyRollbackFailed: (routeId: number): string =>
|
||||
`ВНИМАНИЕ: маршрут #${routeId} создан, но шаги скопированы не полностью, и удалить его автоматически не вышло. Удалите маршрут вручную.`,
|
||||
|
||||
deleteTitle: 'Удалить маршрут?',
|
||||
deleteText: (routeId: number): string =>
|
||||
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`
|
||||
@@ -193,10 +227,20 @@ export const texts = {
|
||||
routeSteps: {
|
||||
breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`,
|
||||
title: (routeId: number): string => `Маршрут #${routeId}`,
|
||||
subtitle: (requestTypeName: string): string => requestTypeName,
|
||||
/** Подзаголовок экрана шагов: по нему видно, ЧЕЙ это маршрут (тип заявки · компания · отдел). */
|
||||
subtitle: (requestTypeName: string, company: string, department: string): string =>
|
||||
`${requestTypeName} · ${company} · ${department}`,
|
||||
/** Маршрут без компании/отдела — запасной, применяется когда своего маршрута нет. */
|
||||
anyCompany: 'любая компания',
|
||||
anyDepartment: 'любой отдел',
|
||||
back: 'К маршрутам',
|
||||
add: 'Добавить шаг',
|
||||
emptyList: 'Шагов пока нет — добавьте первый.',
|
||||
moveUp: 'Шаг выше',
|
||||
moveDown: 'Шаг ниже',
|
||||
mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)',
|
||||
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
|
||||
reorderFailed: (message: string): string => `Не удалось изменить порядок шагов: ${message}`,
|
||||
columnOrderNo: '№',
|
||||
columnExecutor: 'Исполнитель',
|
||||
columnStatus: 'Статус',
|
||||
@@ -220,6 +264,32 @@ export const texts = {
|
||||
executorPlaceholder: 'Выберите сотрудника',
|
||||
positionLabel: 'Должность',
|
||||
positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.',
|
||||
// Подсказка «кого найдёт эта должность» — считается по загруженному справочнику.
|
||||
positionPreviewLoading: 'Проверяем по справочнику сотрудников...',
|
||||
positionPreviewFound: (count: number): string =>
|
||||
count === 1
|
||||
? 'Сейчас эту должность занимает:'
|
||||
: `Эту должность занимают ${count} ${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])} — ` +
|
||||
'на согласование пойдёт тот, чья компания совпадёт с компанией инициатора:',
|
||||
// У маршрута задана конкретная компания (не запасной «любая компания») —
|
||||
// резолвиться будет только сотрудник этой компании, остальные найденные
|
||||
// (в других компаниях) этому маршруту не подойдут ни при каких условиях.
|
||||
positionPreviewFoundInCompany: (company: string, count: number): string =>
|
||||
count === 1
|
||||
? `Эту должность в компании «${company}» (компания маршрута) занимает:`
|
||||
: `Эту должность в компании «${company}» (компания маршрута) занимают ${count} ` +
|
||||
`${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])}:`,
|
||||
positionPreviewWrongCompany: (company: string): string =>
|
||||
`Такая должность есть в справочнике, но не в компании «${company}» — а маршрут применяется именно к её ` +
|
||||
'инициаторам. При резолве шаг останется без исполнителя.',
|
||||
positionPreviewOtherCompanies: 'Есть в других компаниях:',
|
||||
positionPreviewNone:
|
||||
'Точно такой должности в справочнике нет — на резолве шаг останется без исполнителя. Проверьте написание.',
|
||||
positionPreviewSimilar: 'Похожие должности в справочнике (нажмите, чтобы подставить точное написание):',
|
||||
/** Сколько ещё человек занимают ту же должность, кроме показанного. */
|
||||
positionPreviewMore: (count: number): string =>
|
||||
`и ещё ${count} ${pluralRu(count, ['человек', 'человека', 'человек'])}`,
|
||||
positionPreviewNoDictionary: 'Справочник сотрудников недоступен — проверить должность не удалось.',
|
||||
positionPlaceholder: 'Главный инженер',
|
||||
projectRoleLabel: 'Роль в проекте',
|
||||
headTypeLabel: 'Тип руководителя',
|
||||
@@ -233,7 +303,9 @@ export const texts = {
|
||||
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
|
||||
conditionLabel: 'Условие видимости шага',
|
||||
conditionCheckbox: 'Показывать шаг только при выполнении условия',
|
||||
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
|
||||
conditionDisabledHint:
|
||||
'У типа заявки не указан список SharePoint (list_name) — доступно только служебное поле «Инициатор — руководитель».',
|
||||
conditionFieldIsManagerLabel: 'Должность в заявке — руководящая (IsManager)',
|
||||
conditionFieldNameLabel: 'Поле списка',
|
||||
conditionFieldNamePlaceholder: 'Выберите столбец списка',
|
||||
conditionFieldNameLoading: 'Загрузка столбцов списка...',
|
||||
@@ -371,7 +443,10 @@ export const texts = {
|
||||
missingInDictionary: 'Нет в справочнике',
|
||||
loadingDictionary: 'Загрузка справочника...',
|
||||
searching: 'Поиск...',
|
||||
employeeSearchPlaceholder: 'Поиск по ФИО или email...',
|
||||
// Не перечисляем поля: искать можно и по ФИО, и по email, и по должности
|
||||
// (см. toEmployeeOption — должность попадает в sublabel, а SearchableSelect
|
||||
// ищет и по нему). Перечисление всё равно устарело бы при следующем поле.
|
||||
employeeSearchPlaceholder: 'Поиск...',
|
||||
employeesEmpty: 'Сотрудники не найдены в справочнике'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Employee } from '../models';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from './employee';
|
||||
import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee';
|
||||
|
||||
// Хелпер вместо повторения company/companies в каждом литерале — в тестах
|
||||
// нет нужды в разных компаниях у одного сотрудника (это отдельно проверяется
|
||||
// в matchEmployeesByPosition ниже).
|
||||
function emp(fio: string, email: string, position: string, company = ''): Employee {
|
||||
return { fio, email, position, company, companies: company ? [company] : [] };
|
||||
}
|
||||
|
||||
const employees: Employee[] = [
|
||||
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle' },
|
||||
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела' }
|
||||
emp('Горбенко Павел', 'gorbenko.pavel@tnsu.kz', 'Разработчик middle', 'ТОО TANSU Construction'),
|
||||
emp('Соколов Давид', 'sokolov.david@tnsu.kz', 'Руководитель отдела', 'ТОО TANSU Construction')
|
||||
];
|
||||
|
||||
describe('findEmployeeByEmail', () => {
|
||||
@@ -26,11 +33,74 @@ describe('findEmployeeByEmail', () => {
|
||||
});
|
||||
|
||||
describe('toEmployeeOption', () => {
|
||||
it('строит опцию для SearchableSelect из сотрудника', () => {
|
||||
it('строит опцию для SearchableSelect из сотрудника: должность и email в подписи', () => {
|
||||
expect(toEmployeeOption(employees[0])).toEqual({
|
||||
value: 'gorbenko.pavel@tnsu.kz',
|
||||
label: 'Горбенко Павел',
|
||||
sublabel: 'gorbenko.pavel@tnsu.kz'
|
||||
sublabel: 'Разработчик middle · gorbenko.pavel@tnsu.kz'
|
||||
});
|
||||
});
|
||||
|
||||
// Должность в справочнике заполнена не у всех — тогда подпись не должна
|
||||
// начинаться с висящего разделителя.
|
||||
it('без должности в подписи остаётся только email', () => {
|
||||
expect(toEmployeeOption(emp('Без Должности', 'nobody@tnsu.kz', ''))).toEqual({
|
||||
value: 'nobody@tnsu.kz',
|
||||
label: 'Без Должности',
|
||||
sublabel: 'nobody@tnsu.kz'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchEmployeesByPosition', () => {
|
||||
const staff: Employee[] = [
|
||||
emp('Плотникова Лариса', 'p@tnsu.kz', 'Генеральный директор', 'Компания А'),
|
||||
emp('Самаева Асель', 's@tnsu.kz', 'Директор', 'Компания А'),
|
||||
emp('Ким Николай', 'k@tnsu.kz', 'Управляющий директор', 'Компания Б'),
|
||||
emp('Иванов Иван', 'i1@tnsu.kz', 'Инженер ПТО', 'Компания А'),
|
||||
emp('Петров Пётр', 'i2@tnsu.kz', 'Инженер ПТО', 'Компания Б'),
|
||||
emp('Без Должности', 'n@tnsu.kz', '', 'Компания А')
|
||||
];
|
||||
|
||||
it('пустой ввод — ничего не подбирает', () => {
|
||||
expect(matchEmployeesByPosition(staff, ' ')).toEqual({ exact: [], similar: [] });
|
||||
});
|
||||
|
||||
// Бэкенд ищет точное совпадение должности, поэтому подсказка обязана считать
|
||||
// так же — иначе обещала бы исполнителя там, где его не найдут.
|
||||
it('точное совпадение не зависит от регистра и краевых пробелов', () => {
|
||||
const result = matchEmployeesByPosition(staff, ' генеральный ДИРЕКТОР ');
|
||||
expect(result.exact.map(e => e.fio)).toEqual(['Плотникова Лариса']);
|
||||
});
|
||||
|
||||
it('одну должность в разных компаниях занимают несколько человек — возвращаются все', () => {
|
||||
const result = matchEmployeesByPosition(staff, 'Инженер ПТО');
|
||||
expect(result.exact.map(e => e.company)).toEqual(['Компания А', 'Компания Б']);
|
||||
});
|
||||
|
||||
// 'директор' — сам по себе точное совпадение (должность Самаевой), а
|
||||
// подстрокой входит ещё в две других — те и предлагаются похожими.
|
||||
it('точное совпадение и похожие должности возвращаются одновременно', () => {
|
||||
const result = matchEmployeesByPosition(staff, 'директор');
|
||||
expect(result.exact.map(e => e.fio)).toEqual(['Самаева Асель']);
|
||||
expect(result.similar.map(s => s.position)).toEqual(['Генеральный директор', 'Управляющий директор']);
|
||||
});
|
||||
|
||||
// Список нужен, чтобы подставить точное написание — одинаковые строки в нём бесполезны.
|
||||
it('похожие схлопываются по написанию и считают людей', () => {
|
||||
expect(matchEmployeesByPosition(staff, 'инженер').similar).toEqual([
|
||||
{ position: 'Инженер ПТО', fio: 'Иванов Иван', count: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('точное совпадение не дублируется в похожих', () => {
|
||||
const result = matchEmployeesByPosition(staff, 'Директор');
|
||||
expect(result.exact.map(e => e.fio)).toEqual(['Самаева Асель']);
|
||||
expect(result.similar.some(s => s.position === 'Директор')).toBe(false);
|
||||
});
|
||||
|
||||
it('сотрудники без должности не попадают в подбор', () => {
|
||||
expect(matchEmployeesByPosition(staff, '').exact).toEqual([]);
|
||||
expect(matchEmployeesByPosition(staff, 'директор').similar.some(s => s.position === '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Employee } from '../models';
|
||||
import { ISearchableOption } from '../components/ui';
|
||||
|
||||
/** Опция для SearchableSelect: ФИО — основная строка, email — маленькая подпись под ней. */
|
||||
/**
|
||||
* Опция для SearchableSelect: ФИО — основная строка, должность и email — подпись под ней.
|
||||
*
|
||||
* Должность здесь не только для узнавания однофамильцев: SearchableSelect ищет и по
|
||||
* sublabel (см. filtered там), поэтому согласующего можно найти, набрав «начальник омтс»,
|
||||
* не помня фамилии. Должность может быть пустой (в справочнике не у всех заполнена) —
|
||||
* тогда в подписи остаётся только email.
|
||||
*/
|
||||
export function toEmployeeOption(employee: Employee): ISearchableOption {
|
||||
return { value: employee.email, label: employee.fio, sublabel: employee.email };
|
||||
const sublabel = [employee.position, employee.email].filter(Boolean).join(' · ');
|
||||
return { value: employee.email, label: employee.fio, sublabel };
|
||||
}
|
||||
|
||||
export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined {
|
||||
@@ -11,3 +19,67 @@ export function findEmployeeByEmail(employees: Employee[], email: string | undef
|
||||
const target = email.toLowerCase();
|
||||
return employees.find(e => e.email.toLowerCase() === target);
|
||||
}
|
||||
|
||||
export interface SimilarPosition {
|
||||
/** Написание должности как в справочнике — его и подставляем по клику. */
|
||||
position: string;
|
||||
/**
|
||||
* ФИО первого найденного сотрудника с этой должностью — по одному названию
|
||||
* не всегда понятно, тот ли это человек, что нужен; имя снимает вопрос сразу,
|
||||
* без подстановки и повторной проверки.
|
||||
*/
|
||||
fio: string;
|
||||
/** Сколько сотрудников её занимают (в разных компаниях/отделах). */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PositionMatches {
|
||||
/**
|
||||
* Кого бэкенд реально подставит: он ищет ТОЧНОЕ совпадение должности
|
||||
* (/Employee/ByPosition), поэтому здесь тоже точное — с точностью до регистра
|
||||
* и краевых пробелов. Несколько человек — норма: должность одна, а компании
|
||||
* разные, и на resolve выберется тот, чья компания совпала с компанией инициатора.
|
||||
*/
|
||||
exact: Employee[];
|
||||
/**
|
||||
* Похожие должности — на случай опечатки или неполного ввода. Уникальные
|
||||
* по написанию, а не по сотруднику: список нужен, чтобы подставить в поле
|
||||
* точный текст должности, и пять одинаковых строк там бесполезны.
|
||||
* Точное совпадение сюда не попадает — оно уже показано в exact.
|
||||
*/
|
||||
similar: SimilarPosition[];
|
||||
}
|
||||
|
||||
/** Максимум похожих в подсказке — длинный список её только зашумит. */
|
||||
const SIMILAR_LIMIT = 6;
|
||||
|
||||
/**
|
||||
* Кого найдёт шаг «по должности в компании инициатора» с таким текстом должности.
|
||||
* Считается по уже загруженному справочнику (без запроса на каждый символ).
|
||||
*/
|
||||
export function matchEmployeesByPosition(employees: Employee[], position: string): PositionMatches {
|
||||
const query = position.trim().toLowerCase();
|
||||
if (!query) return { exact: [], similar: [] };
|
||||
|
||||
const exact: Employee[] = [];
|
||||
// Ключ — нормализованное написание, значение — как показать и сколько человек.
|
||||
const similarByKey = new Map<string, SimilarPosition>();
|
||||
|
||||
employees.forEach(e => {
|
||||
const raw = e.position.trim();
|
||||
const candidate = raw.toLowerCase();
|
||||
if (!candidate) return;
|
||||
|
||||
if (candidate === query) {
|
||||
exact.push(e);
|
||||
return;
|
||||
}
|
||||
if (candidate.indexOf(query) === -1) return;
|
||||
|
||||
const existing = similarByKey.get(candidate);
|
||||
if (existing) existing.count += 1;
|
||||
else similarByKey.set(candidate, { position: raw, fio: e.fio, count: 1 });
|
||||
});
|
||||
|
||||
return { exact, similar: Array.from(similarByKey.values()).slice(0, SIMILAR_LIMIT) };
|
||||
}
|
||||
|
||||
@@ -44,11 +44,28 @@ describe('detectExecutorKind', () => {
|
||||
});
|
||||
|
||||
describe('describeStepExecutor', () => {
|
||||
const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }];
|
||||
const employees: Employee[] = [
|
||||
{
|
||||
fio: 'Горбенко Павел',
|
||||
email: 'gorbenko.pavel@tnsu.kz',
|
||||
position: 'Разработчик',
|
||||
company: 'ТОО TANSU Construction',
|
||||
companies: ['ТОО TANSU Construction']
|
||||
}
|
||||
];
|
||||
|
||||
it('для email найденного сотрудника показывает ФИО и email отдельной строкой', () => {
|
||||
it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
|
||||
const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees);
|
||||
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'gorbenko.pavel@tnsu.kz' });
|
||||
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'Разработчик · gorbenko.pavel@tnsu.kz' });
|
||||
});
|
||||
|
||||
// Должность в справочнике заполнена не у всех — тогда подпись не должна
|
||||
// начинаться с висящего разделителя.
|
||||
it('для сотрудника без должности в подписи остаётся только email', () => {
|
||||
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [
|
||||
{ fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '', companies: [] }
|
||||
]);
|
||||
expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
|
||||
});
|
||||
|
||||
it('для email, которого нет в справочнике, показывает сам email без secondary', () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export function detectExecutorKind(step: {
|
||||
|
||||
export interface StepExecutorDisplay {
|
||||
primary: string;
|
||||
/** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
|
||||
/** Должность и email под ФИО — когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
|
||||
secondary?: string;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ export interface StepExecutorDisplay {
|
||||
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
|
||||
if (step.executorEmail) {
|
||||
const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined);
|
||||
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
|
||||
if (!employee) return { primary: step.executorEmail };
|
||||
// Должность рядом с email: по одному ФИО не видно, тот ли это человек, что
|
||||
// задумывался при настройке маршрута (в справочнике есть однофамильцы).
|
||||
return { primary: employee.fio, secondary: [employee.position, employee.email].filter(Boolean).join(' · ') };
|
||||
}
|
||||
if (step.position) return { primary: step.position };
|
||||
if (step.projectRole && step.projectRole !== ProjectRole.None) {
|
||||
|
||||
Reference in New Issue
Block a user