diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs index f25a271..f2992f6 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs @@ -51,4 +51,27 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll return File(file, XlsxContentType); } + + /// + /// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип). + /// POST, потому что строки собирает админка — у неё уже есть справочники + /// сотрудников и подписи шагов (см. RouteExportWorkbook). + /// + [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); + } } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs new file mode 100644 index 0000000..c7cfc4b --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs @@ -0,0 +1,143 @@ +using NPOI.SS.UserModel; +using NPOI.SS.Util; +using NPOI.XSSF.UserModel; + +namespace Tnsu.ApprovalRoute.Application.Reports; + +/// Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка). +public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition); + +/// +/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов +/// (по компании/отделу) и их нужно различать на одном листе; у единственного +/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». +/// +public sealed record RouteExportSection(string? Title, IReadOnlyList Rows); + +/// Лист выгрузки — один тип заявки. +public sealed record RouteExportSheet(string Name, IReadOnlyList Sections); + +public sealed record RouteExportRequest(IReadOnlyList Sheets); + +/// +/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: +/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». +/// +/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки: +/// там уже есть справочники сотрудников и логика описания шага, дублировать их +/// на бэкенде незачем. Здесь только вёрстка файла. +/// +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(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 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(); +} diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index efaa5b9..b568590 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -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 = ({ api, dictionary, sharePoint route={route.resolve} /> )} + {route.tab === 'routesExport' && } {route.tab === 'manageRequests' && ( )} diff --git a/frontend/src/webparts/approvalAdmin/components/app/types.ts b/frontend/src/webparts/approvalAdmin/components/app/types.ts index 0f17ad6..2af6f6c 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/types.ts +++ b/frontend/src/webparts/approvalAdmin/components/app/types.ts @@ -5,5 +5,6 @@ export type ScreenKey = | 'routes' | 'routeSteps' | 'resolve' + | 'routesExport' | 'manageRequests' | 'manageByUser'; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx new file mode 100644 index 0000000..f0c5270 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx @@ -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 = ({ api, dictionary }) => { + const requestTypes = useRequestTypes(api); + const employees = usePortalEmployees(dictionary); + const portalDictionaries = usePortalDictionaries(dictionary); + + const [selected, setSelected] = React.useState([]); + const [isBusy, setIsBusy] = React.useState(false); + const [error, setError] = React.useState(); + + 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 ( +
+ + + {isBusy ? texts.routesExport.exporting : texts.routesExport.export} + + } + /> + + {requestTypes.error && {requestTypes.error}} + {employees.error && {employees.error}} + {error && {error}} + + +

{texts.routesExport.hint}

+ {requestTypes.isLoading &&

{texts.common.loading}

} + {!requestTypes.isLoading && requestTypes.items.length === 0 &&

{texts.routesExport.emptyTypes}

} + {requestTypes.items.length > 0 && ( +
+ setSelected(checked ? allIds : [])} + /> + {requestTypes.items.map(rt => ( + toggle(rt.id, checked)} + /> + ))} +

{texts.routesExport.selected(selected.length)}

+
+ )} +
+
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index 4c8e0dc..7cd0345 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -9,3 +9,4 @@ export * from './templateRouteSteps'; export * from './resolve'; export * from './requestManage'; export * from './manageByUser'; +export * from './routeExport'; diff --git a/frontend/src/webparts/approvalAdmin/models/routeExport.ts b/frontend/src/webparts/approvalAdmin/models/routeExport.ts new file mode 100644 index 0000000..1a41943 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/models/routeExport.ts @@ -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[]; +} diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index fe8ba58..a094c36 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -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', diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index 1c041f1..8df7528 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -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 => + this.http.postForBlob('/api/reports/routes', body) + }; } diff --git a/frontend/src/webparts/approvalAdmin/services/httpClient.ts b/frontend/src/webparts/approvalAdmin/services/httpClient.ts index 399b1e5..db7c264 100644 --- a/frontend/src/webparts/approvalAdmin/services/httpClient.ts +++ b/frontend/src/webparts/approvalAdmin/services/httpClient.ts @@ -26,6 +26,8 @@ export interface IHttpClient { put(path: string, body?: unknown): Promise; patch(path: string, body?: unknown): Promise; delete(path: string): Promise; + /** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */ + postForBlob(path: string, body: unknown): Promise; } function buildUrl(baseUrl: string, path: string, query?: QueryParams): string { @@ -105,6 +107,21 @@ export class AadApiHttpClient implements IHttpClient { await this.send('DELETE', path); } + public async postForBlob(path: string, body: unknown): Promise { + 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(response); + throw new ApiClientError(response.status, [`HTTP ${response.status}`]); + } + return response.blob(); + } + private send(method: string, path: string, body?: unknown): Promise { const url = buildUrl(this.baseUrl, path); return this.client diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index b6048f2..59c4c0e 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -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: 'Настройка согласований', diff --git a/frontend/src/webparts/approvalAdmin/utils/routeExport.ts b/frontend/src/webparts/approvalAdmin/utils/routeExport.ts new file mode 100644 index 0000000..7974f1b --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/utils/routeExport.ts @@ -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) })) + }; +}