Добавить вкладку «Выгрузка маршрутов» и перенести «Доступ к заявкам» вниз меню

Выбранные типы заявок выгружаются в xlsx (лист на тип: №, роль, исполнитель,
условие) через POST /api/reports/routes. Пункт меню стоит после «Проверки
маршрута», «Доступ к заявкам» — последним.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-12 16:18:51 +05:00
parent 05f73d356a
commit 6a01101054
12 changed files with 428 additions and 3 deletions
@@ -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) }))
};
}