раздел «Отчёты» в боковом меню — скачивание отчёта по вакансиям (#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
@@ -185,7 +185,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
(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}`;
@@ -16,8 +16,8 @@ function parseJsonArray<T>(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);
@@ -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: '',
@@ -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" },
@@ -10,7 +10,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ 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<ISectionProps> = ({ data, set, context
/>
</FormField>
{isReplacement && (
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="ReplacedEmployee" full>
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="HireReplacedEmployee" full>
<TextInput
id="ReplacedEmployee"
value={data.ReplacedEmployee}
onChange={(v) => set('ReplacedEmployee', v)}
id="HireReplacedEmployee"
value={data.HireReplacedEmployee}
onChange={(v) => set('HireReplacedEmployee', v)}
/>
</FormField>
)}
@@ -5,11 +5,11 @@ import { ISectionProps } from '../model';
export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Кандидат" icon="people">
<FormField label="Ф.И.О. кандидата" htmlFor="CandidateName" required full>
<TextInput id="CandidateName" value={data.CandidateName} onChange={(v) => set('CandidateName', v)} />
<FormField label="Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
<TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
</FormField>
<FormField label="Дата приёма" htmlFor="HireDate">
<TextInput id="HireDate" type="date" value={data.HireDate} onChange={(v) => set('HireDate', v)} />
<FormField label="Дата приёма" htmlFor="HireHireDate">
<TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
@@ -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(' — ');
}
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
@@ -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 },
@@ -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" },
@@ -11,7 +11,7 @@ import {
} from '../model';
export const ApplicationSection: React.FC<ISectionProps> = ({ 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<ISectionProps> = ({ data, set, context
onChange={(v) => set('ClosingDeadline', v)}
/>
</FormField>
<FormField label="1.5 Причина открытия вакансии" htmlFor="OpenReason">
<FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason">
<Select
id="OpenReason"
value={data.OpenReason}
id="VacancyOpenReason"
value={data.VacancyOpenReason}
onChange={(v) => {
set('OpenReason', v);
if (v === '' || v === OPEN_REASON_NEW) set('OpenReasonDetails', '');
set('VacancyOpenReason', v);
if (v === '' || v === OPEN_REASON_NEW) set('VacancyOpenReasonDetails', '');
}}
options={OPEN_REASON_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
{showReasonDetails && (
<FormField label={reasonDetailsLabel} htmlFor="OpenReasonDetails">
<FormField label={reasonDetailsLabel} htmlFor="VacancyOpenReasonDetails">
<TextInput
id="OpenReasonDetails"
value={data.OpenReasonDetails}
onChange={(v) => set('OpenReasonDetails', v)}
id="VacancyOpenReasonDetails"
value={data.VacancyOpenReasonDetails}
onChange={(v) => set('VacancyOpenReasonDetails', v)}
/>
</FormField>
)}
@@ -9,12 +9,12 @@ export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
const { data: hire } = await loadHireRequest(hireId);
return {
...EMPTY_DATA,
FullName: hire.CandidateName,
FullName: hire.HireCandidateName,
Phone: hire.Phone,
Position: hire.Position,
Department: hire.Department,
WorkKind: hire.WorkKind,
Location: hire.SiteLocation,
StartDate: hire.HireDate
StartDate: hire.HireHireDate
};
}