Выгрузка маршрутов: сборка таблицы перенесена на бэкенд, добавлены организация, комментарий и деление на согласующих/исполнителей
Бэкенд сам читает маршруты, шаги и справочники портала (сотрудники с должностями, компании, отделы), фронт шлёт только id типов заявок и скачивает файл. Колонки: №, Роль, Исполнитель, Организация, Условие, Комментарий. Согласующие и исполнители — отдельными таблицами. Условие по CompaniesJson показывается как «организация». Директор Tansu и Управляющий директор Tansu выводятся с ФИО. Комментарий «назначает согласующего/исполнителя» — при галочке переназначения. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -186,7 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
route={route.resolve}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routesExport' && <RoutesExportScreen api={api} dictionary={dictionary} />}
|
||||
{route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
|
||||
{route.tab === 'manageRequests' && (
|
||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
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 { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
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;
|
||||
}
|
||||
|
||||
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
|
||||
@@ -31,10 +25,8 @@ function download(blob: Blob, fileName: string): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, dictionary }) => {
|
||||
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
@@ -51,27 +43,12 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
|
||||
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 }))
|
||||
api.reports.routes(selected)
|
||||
.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 }]} />
|
||||
@@ -83,7 +60,7 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
|
||||
<Button
|
||||
variant="accent"
|
||||
icon="check"
|
||||
disabled={selected.length === 0 || isBusy || dictionariesLoading}
|
||||
disabled={selected.length === 0 || isBusy}
|
||||
onClick={runExport}
|
||||
>
|
||||
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
|
||||
@@ -92,7 +69,6 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
|
||||
/>
|
||||
|
||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||
{employees.error && <Alert kind="err">{employees.error}</Alert>}
|
||||
{error && <Alert kind="err">{error}</Alert>}
|
||||
|
||||
<Panel>
|
||||
|
||||
@@ -9,4 +9,3 @@ export * from './templateRouteSteps';
|
||||
export * from './resolve';
|
||||
export * from './requestManage';
|
||||
export * from './manageByUser';
|
||||
export * from './routeExport';
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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[];
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
PatchTemplateRouteInput,
|
||||
PatchTemplateRouteStepInput,
|
||||
RequestListPartition,
|
||||
RouteExportRequest,
|
||||
RequestType,
|
||||
RequestTypeAccess,
|
||||
RequestTypeListSeries,
|
||||
@@ -176,7 +175,7 @@ export class ApprovalRouteApi {
|
||||
|
||||
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
|
||||
public readonly reports = {
|
||||
routes: (body: RouteExportRequest): Promise<Blob> =>
|
||||
this.http.postForBlob('/api/reports/routes', body)
|
||||
routes: (requestTypeIds: number[]): Promise<Blob> =>
|
||||
this.http.postForBlob('/api/reports/routes', { requestTypeIds })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,16 +211,12 @@ export const texts = {
|
||||
breadcrumb: 'Выгрузка маршрутов',
|
||||
subtitle: 'Маршруты согласования в Excel',
|
||||
title: 'Выгрузка маршрутов',
|
||||
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (роль, исполнитель, условие).',
|
||||
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}`
|
||||
},
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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