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;