From ddce6b4f25d2d85580b5b1af213705a40f2406c9 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 31 Aug 2026 11:00:18 +0000 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=C2=AB?= =?UTF-8?q?=D0=9E=D1=82=D1=87=D1=91=D1=82=D1=8B=C2=BB=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE=D0=BC=20=D0=BC=D0=B5=D0=BD=D1=8E?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D1=8F=D0=BC?= =?UTF-8?q?=20(#17)?= 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 --------- Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/17 --- .../components/RequestPortal.tsx | 23 ++++++ .../reports/ReportsFeature.module.scss | 19 +++++ .../features/reports/ReportsFeature.tsx | 80 +++++++++++++++++++ .../RecruitingHire/RecruitingHireForm.tsx | 2 +- .../forms/RecruitingHire/load.ts | 6 +- .../forms/RecruitingHire/model.ts | 21 +++-- .../forms/RecruitingHire/provisioning.json | 6 +- .../sections/ApplicationSection.tsx | 10 +-- .../sections/CandidateSection.tsx | 8 +- .../forms/RecruitingHire/submit.ts | 2 +- .../forms/RecruitingSelection/model.ts | 17 ++-- .../RecruitingSelection/provisioning.json | 4 +- .../sections/ApplicationSection.tsx | 20 ++--- .../forms/WorkplaceSetup/prefill.ts | 4 +- .../shared/reports/reports.service.ts | 69 ++++++++++++++++ .../shared/reports/reportsAccess.ts | 33 ++++++++ src/webparts/requestPortal/shared/routing.ts | 4 +- tools/sp/README.md | 60 +++++++++++++- 18 files changed, 341 insertions(+), 47 deletions(-) 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/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index b061296..44129b5 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -185,7 +185,7 @@ const RecruitingHireForm: React.FC = ({ (login) => approval.getJobTitle(login) ); - const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0; + const isValid = data.HireCandidateName.trim().length > 0 && data.Position.trim().length > 0; const validate = (): string | undefined => { if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.'; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index 505634b..dfba7e7 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -16,8 +16,8 @@ function parseJsonArray(raw: unknown): T[] { // Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке. const SCALAR_FIELDS: (keyof IFormData)[] = [ - 'Phone', 'HireType', 'ReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode', - 'CandidateName', 'HireDate', 'Position', 'WorkKind', + 'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode', + 'HireCandidateName', 'HireHireDate', 'Position', 'WorkKind', 'Department', 'DepartmentId', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', 'AgencyName', 'AgencyTerms', @@ -58,7 +58,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise< const value = item[key]; if (value === null || value === undefined) { writable[key] = ''; - } else if (key === 'HireDate' && typeof value === 'string') { + } else if (key === 'HireHireDate' && typeof value === 'string') { writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date } else { writable[key] = String(value); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/model.ts b/src/webparts/requestPortal/forms/RecruitingHire/model.ts index f6f1575..4fd8822 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/model.ts @@ -18,7 +18,16 @@ export interface IFormData { Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson Phone: string; HireType: string; - ReplacedEmployee: string; + /** + * Префикс Hire — обход коллизии managed properties: этот internalName + * (без префикса) используется и в форме «Реферальная программа» — + * ДРУГИМ полем с ДРУГИМ Id. Search индексировал managed property только у + * одного из двух списков сайта (см. sp-write props/field в tools; тот же + * приём, что VacancyOpenReason в RecruitingSelection). CandidateName и + * HireDate ниже переименованы по той же причине; ReferralForm НЕ трогать — + * там имя осталось прежним. + */ + HireReplacedEmployee: string; // Проект/объект (справочник /Project) — справочное поле, на маршрут // согласования НЕ влияет (в отличие от RecruitingSelection/TmcTransfer). Project: string; @@ -30,8 +39,8 @@ export interface IFormData { SelectionRequestListId: string; // url партиции заявки на подбор (→ rplist) SelectionRequestCode: string; // уникальный код для подписи/ссылки // Кандидат - CandidateName: string; - HireDate: string; + HireCandidateName: string; + HireHireDate: string; Position: string; WorkKind: string; /** @@ -75,15 +84,15 @@ export const EMPTY_DATA: IFormData = { Companies: [], Phone: '', HireType: '', - ReplacedEmployee: '', + HireReplacedEmployee: '', Project: '', ProjectId: '', ProjectCode: '', SelectionRequestId: '', SelectionRequestListId: '', SelectionRequestCode: '', - CandidateName: '', - HireDate: '', + HireCandidateName: '', + HireHireDate: '', Position: '', WorkKind: '', Department: '', diff --git a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json index 90d0063..655749c 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json @@ -8,7 +8,7 @@ { "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] }, - { "internalName": "ReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, + { "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, { "internalName": "Project", "title": "Проект", "type": "Text" }, { "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" }, @@ -18,8 +18,8 @@ { "internalName": "SelectionRequestListId", "title": "Заявка на подбор — партиция (listId)", "type": "Text" }, { "internalName": "SelectionRequestCode", "title": "Заявка на подбор — код", "type": "Text" }, - { "internalName": "CandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true }, - { "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, + { "internalName": "HireCandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true }, + { "internalName": "HireHireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "WorkKind", "title": "Вид работ", "type": "Text" }, { "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx index 2da4c09..3098978 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx @@ -10,7 +10,7 @@ export const ApplicationSection: React.FC = ({ data, set, context const handleHireTypeChange = (value: string): void => { set('HireType', value); if (value !== HIRE_TYPE_REPLACEMENT) { - set('ReplacedEmployee', ''); // при не-замене очищаем скрытое поле + set('HireReplacedEmployee', ''); // при не-замене очищаем скрытое поле } }; @@ -54,11 +54,11 @@ export const ApplicationSection: React.FC = ({ data, set, context /> {isReplacement && ( - + set('ReplacedEmployee', v)} + id="HireReplacedEmployee" + value={data.HireReplacedEmployee} + onChange={(v) => set('HireReplacedEmployee', v)} /> )} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx index 437847b..bba23f7 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx @@ -5,11 +5,11 @@ import { ISectionProps } from '../model'; export const CandidateSection: React.FC = ({ data, set, context }) => ( - - set('CandidateName', v)} /> + + set('HireCandidateName', v)} /> - - set('HireDate', v)} /> + + set('HireHireDate', v)} /> set('Position', v)} /> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index cab3a43..e0b4121 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -16,7 +16,7 @@ export function hireApprovalService(listId?: string): ApprovalListService { /** Осмысленный заголовок элемента списка (системная колонка Title). */ function buildTitle(data: IFormData): string { - return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — '); + return ['Прием', data.HireCandidateName, data.Position].filter(Boolean).join(' — '); } /** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */ diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/model.ts b/src/webparts/requestPortal/forms/RecruitingSelection/model.ts index 36c1d24..61ca556 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/model.ts @@ -18,8 +18,15 @@ export interface IFormData { Responsible: string; SubmitDate: string; ClosingDeadline: string; - OpenReason: string; - OpenReasonDetails: string; + /** + * Префикс Vacancy — не для читаемости, а обход коллизии managed properties: + * колонка называлась просто OpenReason, и Search индексировал её только у + * ОДНОГО из списков сайта с таким internal name (см. sp-write props/field в + * tools). Переименовали и перенесли данные скриптом (2026-08-29) — трогать + * имя обратно нельзя, коллизия вернётся. + */ + VacancyOpenReason: string; + VacancyOpenReasonDetails: string; Companies: ICompany[]; ObjectProject: string; ObjectProjectId: string; @@ -92,8 +99,8 @@ export const EMPTY_DATA: IFormData = { Responsible: '', SubmitDate: '', ClosingDeadline: '', - OpenReason: '', - OpenReasonDetails: '', + VacancyOpenReason: '', + VacancyOpenReasonDetails: '', Companies: [], ObjectProject: '', ObjectProjectId: '', @@ -149,7 +156,7 @@ export const OPEN_REASON_REPLACE = 'Замена уволенного сотру export const OPEN_REASON_TRANSFER = 'Перевод сотрудника'; export const OPEN_REASON_NEW = 'Новая штатная единица'; -/** Варианты «Причина открытия вакансии» — совпадают с choices колонки OpenReason. */ +/** Варианты «Причина открытия вакансии» — совпадают с choices колонки VacancyOpenReason. */ export const OPEN_REASON_OPTIONS: ISelectOption[] = [ { value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE }, { value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER }, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json index 78cf996..c575ce6 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json @@ -5,8 +5,8 @@ { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true }, - { "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] }, - { "internalName": "OpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" }, + { "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] }, + { "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" }, { "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" }, { "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" }, { "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx index d540745..c0fa2cf 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx @@ -11,7 +11,7 @@ import { } from '../model'; export const ApplicationSection: React.FC = ({ data, set, context, onResponsibleChange }) => { - const reason = data.OpenReason; + const reason = data.VacancyOpenReason; const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW; const reasonDetailsLabel = reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину'; @@ -45,24 +45,24 @@ export const ApplicationSection: React.FC = ({ data, set, context onChange={(v) => set('ClosingDeadline', v)} /> - +