Feature/routes export #29
@@ -51,4 +51,27 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll
|
||||
|
||||
return File(file, XlsxContentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
||||
/// POST, потому что строки собирает админка — у неё уже есть справочники
|
||||
/// сотрудников и подписи шагов (см. RouteExportWorkbook).
|
||||
/// </summary>
|
||||
[HttpPost("routes")]
|
||||
[Produces(XlsxContentType)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult Routes([FromBody] RouteExportRequest request)
|
||||
{
|
||||
if (request.Sheets is not { Count: > 0 })
|
||||
return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } });
|
||||
|
||||
var file = RouteExportWorkbook.Build(request);
|
||||
|
||||
var disposition = new ContentDispositionHeaderValue("attachment");
|
||||
disposition.SetHttpFileName(RouteExportWorkbook.FileName);
|
||||
Response.Headers.ContentDisposition = disposition.ToString();
|
||||
|
||||
return File(file, XlsxContentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using NPOI.SS.UserModel;
|
||||
using NPOI.SS.Util;
|
||||
using NPOI.XSSF.UserModel;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка).</summary>
|
||||
public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition);
|
||||
|
||||
/// <summary>
|
||||
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
|
||||
/// (по компании/отделу) и их нужно различать на одном листе; у единственного
|
||||
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
|
||||
/// </summary>
|
||||
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportRow> Rows);
|
||||
|
||||
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
||||
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
|
||||
|
||||
public sealed record RouteExportRequest(IReadOnlyList<RouteExportSheet> Sheets);
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
||||
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
||||
///
|
||||
/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки:
|
||||
/// там уже есть справочники сотрудников и логика описания шага, дублировать их
|
||||
/// на бэкенде незачем. Здесь только вёрстка файла.
|
||||
/// </summary>
|
||||
public static class RouteExportWorkbook
|
||||
{
|
||||
private const int MaxSheetNameLength = 31;
|
||||
private const string InvalidSheetNameChars = "[]:*?/\\";
|
||||
|
||||
private static readonly (string Title, double Width)[] Columns =
|
||||
[
|
||||
(" ", 6.0),
|
||||
("Роль ", 41.1),
|
||||
("Исполнитель ", 42.2),
|
||||
("Условие ", 66.2),
|
||||
];
|
||||
|
||||
public const string FileName = "Маршруты согласования.xlsx";
|
||||
|
||||
public static byte[] Build(RouteExportRequest request)
|
||||
{
|
||||
var workbook = new XSSFWorkbook();
|
||||
|
||||
var headerStyle = CreateStyle(workbook, bold: true);
|
||||
var cellStyle = CreateStyle(workbook, bold: false);
|
||||
var titleStyle = CreateStyle(workbook, bold: true, border: false);
|
||||
|
||||
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var sheetData in request.Sheets)
|
||||
{
|
||||
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
sheet.SetColumnWidth(i, (int)(Columns[i].Width * 256));
|
||||
|
||||
var rowIndex = 0;
|
||||
foreach (var section in sheetData.Sections)
|
||||
{
|
||||
if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(section.Title))
|
||||
{
|
||||
var titleRow = sheet.CreateRow(rowIndex);
|
||||
var titleCell = titleRow.CreateCell(0);
|
||||
titleCell.SetCellValue(section.Title);
|
||||
titleCell.CellStyle = titleStyle;
|
||||
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
var header = sheet.CreateRow(rowIndex++);
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
{
|
||||
var cell = header.CreateCell(i);
|
||||
cell.SetCellValue(Columns[i].Title);
|
||||
cell.CellStyle = headerStyle;
|
||||
}
|
||||
|
||||
foreach (var row in section.Rows)
|
||||
{
|
||||
var excelRow = sheet.CreateRow(rowIndex++);
|
||||
var numberCell = excelRow.CreateCell(0);
|
||||
numberCell.SetCellValue(row.Number);
|
||||
numberCell.CellStyle = cellStyle;
|
||||
WriteText(excelRow, 1, row.Role, cellStyle);
|
||||
WriteText(excelRow, 2, row.Executor, cellStyle);
|
||||
WriteText(excelRow, 3, row.Condition, cellStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.Write(stream, leaveOpen: true);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteText(IRow row, int column, string value, ICellStyle style)
|
||||
{
|
||||
var cell = row.CreateCell(column);
|
||||
cell.SetCellValue(value);
|
||||
cell.CellStyle = style;
|
||||
}
|
||||
|
||||
private static ICellStyle CreateStyle(IWorkbook workbook, bool bold, bool border = true)
|
||||
{
|
||||
var font = workbook.CreateFont();
|
||||
font.IsBold = bold;
|
||||
|
||||
var style = workbook.CreateCellStyle();
|
||||
style.SetFont(font);
|
||||
style.WrapText = true;
|
||||
style.VerticalAlignment = VerticalAlignment.Top;
|
||||
if (border)
|
||||
{
|
||||
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
// Имя листа Excel: до 31 символа, без []:*?/\, уникальное без учёта регистра.
|
||||
private static string UniqueSheetName(string raw, HashSet<string> used)
|
||||
{
|
||||
var cleaned = new string((raw ?? string.Empty)
|
||||
.Select(c => InvalidSheetNameChars.Contains(c) ? ' ' : c)
|
||||
.ToArray()).Trim();
|
||||
if (cleaned.Length == 0) cleaned = "Лист";
|
||||
|
||||
var candidate = Truncate(cleaned, MaxSheetNameLength);
|
||||
for (var n = 2; !used.Add(candidate); n++)
|
||||
{
|
||||
var suffix = $" ({n})";
|
||||
candidate = Truncate(cleaned, MaxSheetNameLength - suffix.Length) + suffix;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int length) =>
|
||||
value.Length <= length ? value : value[..length].TrimEnd();
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||
import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
||||
import { RoutesExportScreen } from '../screens/RoutesExportScreen';
|
||||
|
||||
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
||||
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
||||
@@ -28,11 +29,12 @@ import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
||||
const NAV_ITEMS: ISidebarItem[] = [
|
||||
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||
{ id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' },
|
||||
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||
{ id: 'routesExport', title: texts.nav.routesExport, icon: 'document' },
|
||||
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' }
|
||||
{ id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' },
|
||||
{ id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' }
|
||||
];
|
||||
|
||||
function sidebarActiveId(tab: ScreenKey): string {
|
||||
@@ -184,6 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
route={route.resolve}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routesExport' && <RoutesExportScreen api={api} dictionary={dictionary} />}
|
||||
{route.tab === 'manageRequests' && (
|
||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||
)}
|
||||
|
||||
@@ -5,5 +5,6 @@ export type ScreenKey =
|
||||
| 'routes'
|
||||
| 'routeSteps'
|
||||
| 'resolve'
|
||||
| 'routesExport'
|
||||
| 'manageRequests'
|
||||
| 'manageByUser';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport';
|
||||
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
|
||||
|
||||
export interface IRoutesExportScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
}
|
||||
|
||||
function download(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, dictionary }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
const [selected, setSelected] = React.useState<number[]>([]);
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const allIds = requestTypes.items.map(rt => rt.id);
|
||||
const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1);
|
||||
|
||||
const toggle = (id: number, checked: boolean): void =>
|
||||
setSelected(current => (checked ? [...current, id] : current.filter(x => x !== id)));
|
||||
|
||||
const runExport = (): void => {
|
||||
setError(undefined);
|
||||
setIsBusy(true);
|
||||
// Порядок листов — как в списке типов, а не в порядке кликов.
|
||||
const types = requestTypes.items.filter(rt => selected.indexOf(rt.id) !== -1);
|
||||
|
||||
Promise.all(
|
||||
types.map(async rt => {
|
||||
const routes = await api.templateRoutes.list(rt.id);
|
||||
const items: RouteWithSteps[] = await Promise.all(
|
||||
routes.map(async route => ({ route, steps: await api.templateRouteSteps.list(route.id) }))
|
||||
);
|
||||
return buildRouteSheet(rt, items, employees.items, portalDictionaries.companies, portalDictionaries.departments);
|
||||
})
|
||||
)
|
||||
.then(sheets => api.reports.routes({ sheets }))
|
||||
.then(blob => download(blob, texts.routesExport.fileName))
|
||||
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
|
||||
.then(() => setIsBusy(false), () => setIsBusy(false));
|
||||
};
|
||||
|
||||
// Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться.
|
||||
const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="clipboard"
|
||||
subtitle={texts.routesExport.subtitle}
|
||||
title={texts.routesExport.title}
|
||||
actions={
|
||||
<Button
|
||||
variant="accent"
|
||||
icon="check"
|
||||
disabled={selected.length === 0 || isBusy || dictionariesLoading}
|
||||
onClick={runExport}
|
||||
>
|
||||
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||
{employees.error && <Alert kind="err">{employees.error}</Alert>}
|
||||
{error && <Alert kind="err">{error}</Alert>}
|
||||
|
||||
<Panel>
|
||||
<p>{texts.routesExport.hint}</p>
|
||||
{requestTypes.isLoading && <p>{texts.common.loading}</p>}
|
||||
{!requestTypes.isLoading && requestTypes.items.length === 0 && <p>{texts.routesExport.emptyTypes}</p>}
|
||||
{requestTypes.items.length > 0 && (
|
||||
<div>
|
||||
<Checkbox
|
||||
label={texts.routesExport.selectAll}
|
||||
checked={allSelected}
|
||||
onChange={checked => setSelected(checked ? allIds : [])}
|
||||
/>
|
||||
{requestTypes.items.map(rt => (
|
||||
<Checkbox
|
||||
key={rt.id}
|
||||
label={rt.name}
|
||||
checked={selected.indexOf(rt.id) !== -1}
|
||||
onChange={checked => toggle(rt.id, checked)}
|
||||
/>
|
||||
))}
|
||||
<p>{texts.routesExport.selected(selected.length)}</p>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,3 +9,4 @@ export * from './templateRouteSteps';
|
||||
export * from './resolve';
|
||||
export * from './requestManage';
|
||||
export * from './manageByUser';
|
||||
export * from './routeExport';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// DTO эндпоинта POST api/reports/routes (Application/Reports/RouteExportWorkbook.cs).
|
||||
|
||||
export interface RouteExportRow {
|
||||
number: number;
|
||||
role: string;
|
||||
executor: string;
|
||||
condition: string;
|
||||
}
|
||||
|
||||
export interface RouteExportSection {
|
||||
/** Заголовок маршрута — только когда у типа заявки их несколько. */
|
||||
title?: string;
|
||||
rows: RouteExportRow[];
|
||||
}
|
||||
|
||||
/** Лист файла — один тип заявки. */
|
||||
export interface RouteExportSheet {
|
||||
name: string;
|
||||
sections: RouteExportSection[];
|
||||
}
|
||||
|
||||
export interface RouteExportRequest {
|
||||
sheets: RouteExportSheet[];
|
||||
}
|
||||
@@ -89,7 +89,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
||||
|
||||
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
||||
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
||||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
|
||||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'routesExport', 'manageRequests', 'manageByUser', 'requestTypeAccess'];
|
||||
|
||||
const PARAM_KEYS = [
|
||||
'aatab', 'aaroute', 'aarfilter',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PatchTemplateRouteInput,
|
||||
PatchTemplateRouteStepInput,
|
||||
RequestListPartition,
|
||||
RouteExportRequest,
|
||||
RequestType,
|
||||
RequestTypeAccess,
|
||||
RequestTypeListSeries,
|
||||
@@ -172,4 +173,10 @@ export class ApprovalRouteApi {
|
||||
})
|
||||
.then(rows => rows.map(toUserApprovalRow))
|
||||
};
|
||||
|
||||
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
|
||||
public readonly reports = {
|
||||
routes: (body: RouteExportRequest): Promise<Blob> =>
|
||||
this.http.postForBlob('/api/reports/routes', body)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface IHttpClient {
|
||||
put<T>(path: string, body?: unknown): Promise<T>;
|
||||
patch<T>(path: string, body?: unknown): Promise<T>;
|
||||
delete(path: string): Promise<void>;
|
||||
/** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */
|
||||
postForBlob(path: string, body: unknown): Promise<Blob>;
|
||||
}
|
||||
|
||||
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
||||
@@ -105,6 +107,21 @@ export class AadApiHttpClient implements IHttpClient {
|
||||
await this.send<void>('DELETE', path);
|
||||
}
|
||||
|
||||
public async postForBlob(path: string, body: unknown): Promise<Blob> {
|
||||
const url = buildUrl(this.baseUrl, path);
|
||||
const response = await this.client.fetch(url, AadHttpClient.configurations.v1, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Ошибка приходит обычным JSON-конвертом ({ messages } или { errors }) — разбираем тем же парсером.
|
||||
await parseResponse<void>(response);
|
||||
throw new ApiClientError(response.status, [`HTTP ${response.status}`]);
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = buildUrl(this.baseUrl, path);
|
||||
return this.client
|
||||
|
||||
@@ -96,6 +96,7 @@ export const texts = {
|
||||
requestTypeAccess: 'Доступ к заявкам',
|
||||
routes: 'Маршруты',
|
||||
resolve: 'Проверка маршрута',
|
||||
routesExport: 'Выгрузка маршрутов',
|
||||
manageRequests: 'Редактирование заявок',
|
||||
manageByUser: 'Заявки по участнику'
|
||||
},
|
||||
@@ -206,6 +207,24 @@ export const texts = {
|
||||
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
|
||||
},
|
||||
|
||||
routesExport: {
|
||||
breadcrumb: 'Выгрузка маршрутов',
|
||||
subtitle: 'Маршруты согласования в Excel',
|
||||
title: 'Выгрузка маршрутов',
|
||||
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (роль, исполнитель, условие).',
|
||||
selectAll: 'Выбрать все',
|
||||
export: 'Выгрузить',
|
||||
exporting: 'Формирование файла...',
|
||||
emptyTypes: 'Типов заявок пока нет.',
|
||||
selected: (count: number): string => `Выбрано: ${count}`,
|
||||
routeTitle: (company: string, department: string, byProject: boolean): string =>
|
||||
`Маршрут: ${company} · ${department}${byProject ? ' · по проекту' : ''}`,
|
||||
always: 'Всегда',
|
||||
executorByRequest: 'Определяется по заявке',
|
||||
fileName: 'Маршруты согласования.xlsx',
|
||||
failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}`
|
||||
},
|
||||
|
||||
routes: {
|
||||
breadcrumb: 'Маршруты согласования',
|
||||
subtitle: 'Настройка согласований',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Company, Department, Employee, HeadType, ProjectRole, RequestType, RouteExportRow, RouteExportSheet, TemplateRoute, TemplateRouteStep } from '../models';
|
||||
import { headTypeLabels, projectRoleLabels, texts } from '../texts';
|
||||
import { describeStepCondition } from './stepExecutor';
|
||||
import { findEmployeeByEmail } from './employee';
|
||||
import { shortGuid } from './guid';
|
||||
|
||||
export interface RouteWithSteps {
|
||||
route: TemplateRoute;
|
||||
steps: TemplateRouteStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Роль и исполнитель шага для выгрузки — как в ручной таблице HR: «роль» это
|
||||
* должность/тип руководителя, «исполнитель» — конкретный человек, если он
|
||||
* известен уже на этапе настройки маршрута.
|
||||
*/
|
||||
function describeRoleAndExecutor(step: TemplateRouteStep, employees: Employee[]): { role: string; executor: string } {
|
||||
if (step.executorEmail) {
|
||||
const employee = findEmployeeByEmail(employees, step.executorEmail);
|
||||
return { role: employee?.position || texts.common.empty, executor: employee?.fio ?? step.executorEmail };
|
||||
}
|
||||
if (step.position) {
|
||||
const wanted = step.position.trim().toLowerCase();
|
||||
const holder = employees.find(e => e.position.trim().toLowerCase() === wanted);
|
||||
return { role: step.position, executor: holder?.fio ?? texts.common.empty };
|
||||
}
|
||||
if (step.projectRole && step.projectRole !== ProjectRole.None) {
|
||||
return { role: projectRoleLabels[step.projectRole], executor: texts.routesExport.executorByRequest };
|
||||
}
|
||||
if (step.headType && step.headType !== HeadType.None) {
|
||||
return { role: headTypeLabels[step.headType], executor: texts.routesExport.executorByRequest };
|
||||
}
|
||||
return { role: texts.common.empty, executor: texts.common.empty };
|
||||
}
|
||||
|
||||
/** Строки листа: параллельные шаги (равный orderNo) получают один и тот же номер. */
|
||||
export function buildRouteRows(steps: TemplateRouteStep[], employees: Employee[]): RouteExportRow[] {
|
||||
const orderNos = Array.from(new Set(steps.map(s => s.orderNo))).sort((a, b) => a - b);
|
||||
return [...steps]
|
||||
.sort((a, b) => a.orderNo - b.orderNo)
|
||||
.map(step => ({
|
||||
number: orderNos.indexOf(step.orderNo) + 1,
|
||||
...describeRoleAndExecutor(step, employees),
|
||||
condition: describeStepCondition(step) ?? texts.routesExport.always
|
||||
}));
|
||||
}
|
||||
|
||||
/** Лист на тип заявки. Заголовки маршрутов нужны, только когда их у типа несколько. */
|
||||
export function buildRouteSheet(
|
||||
requestType: RequestType,
|
||||
items: RouteWithSteps[],
|
||||
employees: Employee[],
|
||||
companies: Company[],
|
||||
departments: Department[]
|
||||
): RouteExportSheet {
|
||||
const title = (route: TemplateRoute): string | undefined => {
|
||||
if (items.length < 2) return undefined;
|
||||
const company = route.companyId
|
||||
? companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId)
|
||||
: texts.routeSteps.anyCompany;
|
||||
const department = route.departmentId
|
||||
? departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId)
|
||||
: texts.routeSteps.anyDepartment;
|
||||
return texts.routesExport.routeTitle(company, department, route.byProject);
|
||||
};
|
||||
|
||||
return {
|
||||
name: requestType.name,
|
||||
sections: items.map(i => ({ title: title(i.route), rows: buildRouteRows(i.steps, employees) }))
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user