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

Пункт меню виден только заданному списку почт (Тришина, Городецкий,
Горбенко) — группы 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>

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/17
This commit was merged in pull request #17.
This commit is contained in:
2026-08-31 11:00:18 +00:00
parent 6172321710
commit ddce6b4f25
18 changed files with 341 additions and 47 deletions
@@ -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;