From 849aa7964dee25be04c56901201a85be8811ec5c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 10:11:26 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20?= =?UTF-8?q?=C2=AB=D0=9E=D1=82=D1=87=D1=91=D1=82=D1=8B=C2=BB=20=D0=B2=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE=D0=BC=20=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8E=20=E2=80=94=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пункт меню виден только заданному списку почт (Тришина, Городецкий, Горбенко) — группы 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 --- .../components/RequestPortal.tsx | 23 ++++++ .../reports/ReportsFeature.module.scss | 19 +++++ .../features/reports/ReportsFeature.tsx | 80 +++++++++++++++++++ .../shared/reports/reports.service.ts | 69 ++++++++++++++++ .../shared/reports/reportsAccess.ts | 33 ++++++++ src/webparts/requestPortal/shared/routing.ts | 4 +- 6 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/webparts/requestPortal/features/reports/ReportsFeature.module.scss create mode 100644 src/webparts/requestPortal/features/reports/ReportsFeature.tsx create mode 100644 src/webparts/requestPortal/shared/reports/reports.service.ts create mode 100644 src/webparts/requestPortal/shared/reports/reportsAccess.ts diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 97ecd09..a1bce34 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -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 = ({ hasTeamsContext, context const [mobileNavOpen, setMobileNavOpen] = React.useState(false); const [navCounts, setNavCounts] = React.useState(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 = ({ 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 = ({ 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 = ({ hasTeamsContext, context )} + {route.nav === 'reports' && canSeeReports && } + {LIST_VIEWS[route.nav] !== undefined && ( = ({ context }) => { + const today = todayIso(); + const [from, setFrom] = React.useState(firstDayOfMonthIso()); + const [to, setTo] = React.useState(today); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(''); + + 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 ( +
+ + + + + + + + + + + {periodInvalid && Конец периода раньше его начала.} + {error && {error}} + +
+ +
+ +

+ Период отбирает заявки по дате подачи заявки на подбор. Заявки на приём в отчёт + попадают независимо от периода — по факту закрытия вакансии. +

+
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/reports/reports.service.ts b/src/webparts/requestPortal/shared/reports/reports.service.ts new file mode 100644 index 0000000..f6bf1fa --- /dev/null +++ b/src/webparts/requestPortal/shared/reports/reports.service.ts @@ -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 { + 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 { + 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); +} diff --git a/src/webparts/requestPortal/shared/reports/reportsAccess.ts b/src/webparts/requestPortal/shared/reports/reportsAccess.ts new file mode 100644 index 0000000..5441c1d --- /dev/null +++ b/src/webparts/requestPortal/shared/reports/reportsAccess.ts @@ -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 = 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); +} diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index bba1ac1..9637af7 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -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;