feat: раздел «Отчёты» в боковом меню — скачивание отчёта по вакансиям
Tests / frontend-test (pull_request) Successful in 1m17s

Пункт меню виден только заданному списку почт (Тришина, Городецкий,
Горбенко) — группы HR в SharePoint пока нет, добавили TODO завести
проверку по ней отдельно (reportsAccess.ts). Скрыт из меню совсем, а
не показан неактивным, и прямой переход по ?rpnav=reports у остальных
редиректит на «Новую заявку».

Раздел — форма периода (С/По) и кнопка «Скачать xlsx», дергает
GET api/reports/vacancies через тот же AadHttpClient, что и остальные
вызовы approval-route API. Имя файла собирается на фронте по формату
бэкенда (VacancyReportWorkbook.FileName), а не читается из
Content-Disposition — approval-route не объявляет заголовок в
Access-Control-Expose-Headers, браузер бы его не отдал.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-28 10:11:26 +05:00
parent 6172321710
commit 849aa7964d
6 changed files with 227 additions and 1 deletions
@@ -36,6 +36,8 @@ import { SearchView } from '../features/myRequests/SearchView';
import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service';
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
import { isEditableStatus } from '../shared/approval';
import { hasReportsAccess } from '../shared/reports/reportsAccess';
import { ReportsFeature } from '../features/reports/ReportsFeature';
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
// Логики фич тут нет — файл общий, меняется редко.
@@ -70,6 +72,22 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
// Раздел «Отчёты» — точечный список почт, пока в SharePoint нет группы HR
// (см. reportsAccess.ts). Пересчитывается на каждый рендер намеренно: сам
// hasReportsAccess — синхронная проверка без сети, а input (effectiveUserEmail)
// меняется только по подмене личности в property pane, что и так вызывает
// ререндер всей веб-части.
const canSeeReports = hasReportsAccess(context);
// Прямой переход по ссылке (?rpnav=reports) в обход бокового меню — тот же
// раздел, что скрыт из навигации, должен быть недоступен и так: без этого
// редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку.
React.useEffect(() => {
if (route.nav === 'reports' && !canSeeReports) {
navigate({ mode: 'submit', nav: 'new' }, { replace: true });
}
}, [route.nav, canSeeReports]);
// Уникальный код заявки для заголовка Drawer (approve/edit) — сама форма
// узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/
// EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не
@@ -278,6 +296,8 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
};
// Разделы бокового меню для пакетного Sidebar (счётчики — из navCounts).
// «Отчёты» — только у тех, кому разрешено (см. canSeeReports выше): скрыт из
// меню совсем, а не показан неактивным, — раздела для остальных как бы не существует.
const navItems: ISidebarItem[] = [
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
{ id: 'approvals', title: 'На согласовании', icon: 'user-check', count: navCounts?.approvals },
@@ -285,6 +305,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
{ id: 'search', title: 'Поиск', icon: 'search' },
...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []),
];
return (
@@ -375,6 +396,8 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
<OwnTabs activeId={route.ownTab ?? 'created'} onChange={gotoOwnTab} />
)}
{route.nav === 'reports' && canSeeReports && <ReportsFeature context={context} />}
{LIST_VIEWS[route.nav] !== undefined && (
<RequestsTableView
context={context}
@@ -0,0 +1,19 @@
@import '../../../../styles/design-tokens';
.view {
width: 100%;
max-width: 480px;
}
.actions {
display: flex;
align-items: center;
gap: $sp-3;
margin-top: $sp-4;
}
.hint {
margin-top: $sp-2;
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,80 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { Alert, Button, FormCard, FormField, FormGrid, Icon, TextInput } from '@tnsu/ui-kit';
import { downloadVacancyReport } from '../../shared/reports/reports.service';
import styles from './ReportsFeature.module.scss';
// Раздел «Отчёты» — пока один отчёт (по вакансиям), поэтому без списка
// карточек: сразу форма периода + кнопка. Появится второй отчёт — тут
// понадобится список/переключатель, но городить его заранее незачем.
function todayIso(): string {
const now = new Date();
const mm = `${now.getMonth() + 1}`.padStart(2, '0');
const dd = `${now.getDate()}`.padStart(2, '0');
return `${now.getFullYear()}-${mm}-${dd}`;
}
/** Первое число текущего месяца — разумный дефолт периода отчёта. */
function firstDayOfMonthIso(): string {
const now = new Date();
const mm = `${now.getMonth() + 1}`.padStart(2, '0');
return `${now.getFullYear()}-${mm}-01`;
}
export interface IReportsFeatureProps {
context: WebPartContext;
}
export const ReportsFeature: React.FC<IReportsFeatureProps> = ({ context }) => {
const today = todayIso();
const [from, setFrom] = React.useState<string>(firstDayOfMonthIso());
const [to, setTo] = React.useState<string>(today);
const [loading, setLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState<string>('');
const periodInvalid = !!from && !!to && from > to;
const download = (): void => {
if (!from || !to || periodInvalid) return;
setLoading(true);
setError('');
downloadVacancyReport(context, from, to)
.catch((e: Error) => setError(e.message || 'Не удалось сформировать отчёт'))
.finally(() => setLoading(false));
};
return (
<div className={styles.view}>
<FormCard title="Отчёт по вакансиям" subtitle="Заявки на подбор и на приём за выбранный период">
<FormGrid>
<FormField label="С" htmlFor="reportFrom" required>
<TextInput id="reportFrom" type="date" value={from} onChange={setFrom} max={to || undefined} />
</FormField>
<FormField label="По" htmlFor="reportTo" required>
<TextInput id="reportTo" type="date" value={to} onChange={setTo} min={from || undefined} max={today} />
</FormField>
</FormGrid>
{periodInvalid && <Alert variant="warn">Конец периода раньше его начала.</Alert>}
{error && <Alert variant="err">{error}</Alert>}
<div className={styles.actions}>
<Button
variant="accent"
onClick={download}
disabled={loading || !from || !to || periodInvalid}
>
<Icon name="download" size="sm" /> {loading ? 'Формируется…' : 'Скачать xlsx'}
</Button>
</div>
<p className={styles.hint}>
Период отбирает заявки по дате подачи заявки на подбор. Заявки на приём в отчёт
попадают независимо от периода — по факту закрытия вакансии.
</p>
</FormCard>
</div>
);
};
@@ -0,0 +1,69 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config';
// Обращения к api/reports approval-route API — по тому же принципу, что и
// approvalRoute.service.ts (AadHttpClient, SSO текущего пользователя), но
// ответ — не JSON, а сам файл (см. ReportsController.Vacancies).
/** Отчёт по вакансиям за период: даты YYYY-MM-DD (как в TextInput type="date"). */
export async function downloadVacancyReport(
context: WebPartContext,
from: string,
to: string
): Promise<void> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const url = `${getApprovalApiBaseUrl()}/api/reports/vacancies?from=${from}&to=${to}`;
const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(await extractErrorMessage(response));
}
const blob = await response.blob();
// Имя файла собираем сами, а не читаем Content-Disposition: approval-route
// не объявляет его в Access-Control-Expose-Headers (см. Program.cs, CORS),
// и без этого браузер отдал бы fetch-у пустой заголовок. Формат — тот же,
// что и в VacancyReportWorkbook.FileName на бэкенде.
saveBlob(blob, vacancyReportFileName(from, to));
}
/** «Отчет по вакансиям dd.MM.yyyy-dd.MM.yyyy.xlsx» — как в VacancyReportWorkbook.FileName. */
function vacancyReportFileName(from: string, to: string): string {
return `Отчет по вакансиям ${formatRu(from)}-${formatRu(to)}.xlsx`;
}
function formatRu(isoDate: string): string {
const [year, month, day] = isoDate.split('-');
return `${day}.${month}.${year}`;
}
/**
* 400 от ReportsController приходит как { errors: string[] } (см.
* VacancyReportService.GetAsync — например «конец периода раньше начала»);
* остальные статусы — обычный текст/пусто.
*/
async function extractErrorMessage(response: HttpClientResponse): Promise<string> {
const text = await response.text().catch(() => '');
try {
const parsed: { errors?: string[] } = JSON.parse(text);
if (parsed.errors && parsed.errors.length > 0) return parsed.errors.join('; ');
} catch {
// Не JSON — используем как есть.
}
return text || `${response.status} ${response.statusText}`;
}
/** Отдать Blob пользователю как скачивание файла — через временную ссылку. */
function saveBlob(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);
// Отложенный revoke — на некоторых браузерах немедленный обрывает скачивание,
// начатое кликом синхронно перед этим.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
@@ -0,0 +1,33 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { effectiveUserEmail } from '../impersonation';
/**
* Кто видит раздел «Отчёты» в боковом меню. Пока это фиксированный список
* почт — группы HR в SharePoint ещё нет (её заведут отдельно); появится —
* список ниже заменится на реальную проверку членства (см. TODO).
*
* Email — из справочника сотрудников (api.tnsu.kz/Employee/All), а не из
* Title сайта: у части учёток встречаются устаревшие site user записи со
* старым email (пример — Тришина: m.trishina@tnsu.kz как устаревший алиас
* рядом с текущим trishina.mariya@tnsu.kz).
*/
const ALLOWED_EMAILS: ReadonlySet<string> = new Set([
'trishina.mariya@tnsu.kz', // Мария Тришина
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
]);
// TODO: заменить точечный список на членство в SharePoint-группе HR, когда
// её заведут — тогда список выше станет фолбэком для тех троих, кто должен
// видеть раздел независимо от состава группы (администраторы портала).
/**
* Есть ли у текущего пользователя доступ к разделу «Отчёты». Использует
* effectiveUserEmail — то есть учитывает отладочную подмену личности (см.
* impersonation.ts): под подменённым пользователем раздел виден/не виден
* так же, как было бы у него самого.
*/
export function hasReportsAccess(context: WebPartContext): boolean {
const email = effectiveUserEmail(context).trim().toLowerCase();
return email.length > 0 && ALLOWED_EMAILS.has(email);
}
+3 -1
View File
@@ -35,7 +35,7 @@ export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
* «Возвращена на доработку»); в отличие от первых двух смотрит
* на список данных по автору, а не на строки маршрута.
*/
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search';
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports';
/**
* Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
@@ -259,6 +259,7 @@ function readStoredNav(): NavSection {
raw === 'rework' ||
raw === 'own' ||
raw === 'search' ||
raw === 'reports' ||
raw === 'new'
? raw
: 'new';
@@ -283,6 +284,7 @@ function parseNav(raw: string | undefined): NavSection {
raw === 'rework' ||
raw === 'own' ||
raw === 'search' ||
raw === 'reports' ||
raw === 'new'
) {
return raw;