fix: внес изменения в заявках рекрутинга от 16.09.26
Tests / frontend-test (push) Successful in 1m20s

This commit is contained in:
Тимур Городецкий
2026-09-16 17:59:45 +05:00
parent 428122d32a
commit 6f8bea33a2
31 changed files with 595 additions and 101 deletions
@@ -194,8 +194,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
const isValid = data.HireCandidateName.trim().length > 0 && data.Position.trim().length > 0;
// При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная.
const mainCompanyMissing = data.Companies.length >= 2 && !data.MainCompanyId;
const isValid = data.HireCandidateName.trim().length > 0 && data.Position.trim().length > 0 && !mainCompanyMissing;
const validate = (): string | undefined => {
if (mainCompanyMissing) return 'Выбрано несколько организаций — укажите, какая из них основное место работы.';
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -1,7 +1,7 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ICompany } from '../../shared/dictionary';
import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare } from './model';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
function parseJsonArray<T>(raw: unknown): T[] {
@@ -16,6 +16,7 @@ function parseJsonArray<T>(raw: unknown): T[] {
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
const SCALAR_FIELDS: (keyof IFormData)[] = [
'MainCompanyId',
'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
'HireCandidateName', 'HireHireDate', 'Position', 'WorkKind',
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
@@ -44,13 +45,15 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId)
.select(...SCALAR_FIELDS, 'CompaniesJson', 'RecruitingAgency', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
.select(...SCALAR_FIELDS, 'CompaniesJson', 'CompanySharesJson', 'RecruitingAgency', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>;
// Мультивыбор компаний хранится Note-колонкой CompaniesJson (не одноимённой колонкой).
(data as unknown as Record<string, unknown>).Companies = parseJsonArray<ICompany>(item.CompaniesJson);
// % ставки по компаниям (совмещение) — аналогично, Note-колонка CompanySharesJson.
(data as unknown as Record<string, unknown>).CompanyShares = parseJsonArray<ICompanyShare>(item.CompanySharesJson);
// Флаг агентства — булево поле (Yes/No), не строка.
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
@@ -13,9 +13,19 @@ export const FORM_ID: RequestTypeId = 'recruiting-hire';
* Данные формы «Заявка на прием». Ключи = internalName колонок списка данных
* этой формы (см. shared/listTitles.ts), чтобы при записи маппинг был 1:1.
*/
/** % ставки на одну из выбранных компаний (см. CompanySharesField) — актуально при совмещении (2+ компании). */
export interface ICompanyShare {
companyId: string;
percent: string;
}
export interface IFormData {
// Заявка (шапка)
Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson
/** Id компании из Companies — «Основное место работы»; '' — не выбрано (или выбрана всего 1 компания). */
MainCompanyId: string;
/** % ставки на каждую компанию из Companies — заполняется при совмещении (2+ компании), хранится CompanySharesJson. */
CompanyShares: ICompanyShare[];
Phone: string;
HireType: string;
/**
@@ -74,6 +84,8 @@ export interface ISectionProps {
export const EMPTY_DATA: IFormData = {
Companies: [],
MainCompanyId: '',
CompanyShares: [],
Phone: '',
HireType: '',
HireReplacedEmployee: '',
@@ -5,6 +5,8 @@
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
{ "internalName": "MainCompanyId", "title": "Основное место работы (Id компании)", "type": "Text" },
{ "internalName": "CompanySharesJson", "title": "Распределение ЗП по компаниям (JSON)", "type": "Note" },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
@@ -17,8 +17,8 @@ export const AgencySection: React.FC<ISectionProps> = ({ data, set }) => {
};
return (
<CollapseSection title="Рекрутинговое агентство" icon="building">
<FormField label="Привлекается рекрутинговое агентство">
<CollapseSection title="5. Рекрутинговое агентство" icon="building">
<FormField label="5.1 Привлекается рекрутинговое агентство">
<Toggle checked={data.RecruitingAgency} onChange={handleToggle} onLabel="Да" />
</FormField>
{data.RecruitingAgency && (
@@ -1,8 +1,9 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
import { CompanyPickerMulti, ProjectPicker } from '../../../shared/dictionary';
import { CompanyPickerMulti, ProjectPicker, ICompany } from '../../../shared/dictionary';
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT } from '../model';
import { SelectionRequestField } from './SelectionRequestField';
import { CompanySharesField } from './CompanySharesField';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => {
const isReplacement = data.HireType === HIRE_TYPE_REPLACEMENT;
@@ -14,18 +15,41 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
}
};
// Компания убрана из выбора — чистим ссылки на неё в «Основное место
// работы» / % ставки, иначе они молча уйдут в заявку по несуществующей компании.
const handleCompaniesChange = (companies: ICompany[]): void => {
set('Companies', companies);
const ids = new Set(companies.map((c) => c.Id));
if (companies.length < 2) {
set('MainCompanyId', '');
set('CompanyShares', []);
return;
}
if (data.MainCompanyId && !ids.has(data.MainCompanyId)) {
set('MainCompanyId', '');
}
set('CompanyShares', data.CompanyShares.filter((s) => ids.has(s.companyId)));
};
return (
<CollapseSection title="Заявка" icon="clipboard">
<FormField label="Организация" htmlFor="Companies" full>
<CollapseSection title="1. Заявка" icon="clipboard">
<FormField label="1.1 Организация" htmlFor="Companies" full>
<CompanyPickerMulti
id="Companies"
context={context}
value={data.Companies}
onChange={(companies) => set('Companies', companies)}
onChange={handleCompaniesChange}
placeholder="Начните вводить название организации…"
/>
</FormField>
<FormField label="Проект" htmlFor="Project" full>
<CompanySharesField
companies={data.Companies}
mainCompanyId={data.MainCompanyId}
onMainCompanyChange={(v) => set('MainCompanyId', v)}
shares={data.CompanyShares}
onSharesChange={(v) => set('CompanyShares', v)}
/>
<FormField label="1.2 Проект" htmlFor="Project" full>
<ProjectPicker
context={context}
id="Project"
@@ -38,13 +62,13 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
placeholder="Начните вводить название или код проекта…"
/>
</FormField>
<FormField label="Заявка на подбор" full>
<FormField label="1.3 Заявка на подбор" full>
<SelectionRequestField data={data} set={set} />
</FormField>
<FormField label="Телефон" htmlFor="Phone">
<FormField label="1.4 Телефон" htmlFor="Phone">
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
</FormField>
<FormField label="Тип приёма" htmlFor="HireType">
<FormField label="1.5 Тип приёма" htmlFor="HireType">
<Select
id="HireType"
value={data.HireType}
@@ -3,23 +3,23 @@ import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { ISectionProps } from '../model';
export const CandidateSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Кандидат" icon="people">
<FormField label="Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
<CollapseSection title="2. Кандидат" icon="people">
<FormField label="2.1 Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
<TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
</FormField>
<FormField label="Дата приёма" htmlFor="HireHireDate">
<FormField label="2.2 Дата приёма" htmlFor="HireHireDate">
<TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<FormField label="2.3 Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Вид работ" htmlFor="WorkKind">
<FormField label="2.4 Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField>
<FormField label="Отдел/Участок" htmlFor="Department">
<FormField label="2.5 Отдел/Участок" htmlFor="Department">
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
</FormField>
<FormField label="Местоположение" htmlFor="SiteLocation">
<FormField label="2.6 Местоположение" htmlFor="SiteLocation">
<TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} />
</FormField>
</CollapseSection>
@@ -0,0 +1,43 @@
@import '../../../../../styles/design-tokens';
.row {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
}
.name {
word-break: break-word;
}
// 46px — под 3 цифры (до 100) с учётом padding borderless-варианта поля
// (6px 8px, см. @tnsu/ui-kit). Нативный спиннер number съедал бы почти всё
// место в таком узком поле — убираем его, стрелки тут не нужны.
.item :global(input) {
width: 46px;
text-align: right;
-moz-appearance: textfield;
}
.item :global(input)::-webkit-outer-spin-button,
.item :global(input)::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.percentSign {
color: $gray-500;
}
@@ -0,0 +1,70 @@
import * as React from 'react';
import styles from './CompanySharesField.module.scss';
import { FormField, Select, TextInput, ISelectOption } from '../../../shared/ui';
import { ICompany } from '../../../shared/dictionary';
import { ICompanyShare } from '../model';
export interface ICompanySharesFieldProps {
companies: ICompany[];
mainCompanyId: string;
onMainCompanyChange: (companyId: string) => void;
shares: ICompanyShare[];
onSharesChange: (shares: ICompanyShare[]) => void;
}
/**
* Совмещение (2+ выбранные компании в «Организация»): выбор основного места
* работы + % ставки на каждую компанию. При 1 компании (или 0) не рендерится —
* совмещать нечего, она и так единственное место работы.
*/
export const CompanySharesField: React.FC<ICompanySharesFieldProps> = ({
companies,
mainCompanyId,
onMainCompanyChange,
shares,
onSharesChange
}) => {
if (companies.length < 2) return null;
const mainOptions: ISelectOption[] = companies.map((c) => ({ value: c.Id, label: c.Name }));
const percentOf = (companyId: string): string => shares.find((s) => s.companyId === companyId)?.percent || '';
const setPercent = (companyId: string, percent: string): void => {
const rest = shares.filter((s) => s.companyId !== companyId);
onSharesChange(percent === '' ? rest : [...rest, { companyId, percent }]);
};
return (
<>
<FormField label="Основное место работы" htmlFor="MainCompanyId" required>
<Select
id="MainCompanyId"
value={mainCompanyId}
onChange={onMainCompanyChange}
options={mainOptions}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Распределение ЗП по компаниям" full>
<div className={styles.row}>
{companies.map((c) => (
<div key={c.Id} className={styles.item}>
<span className={styles.name}>{c.Name}</span>
<TextInput
type="number"
min={0}
max={100}
value={percentOf(c.Id)}
onChange={(v) => setPercent(c.Id, v)}
ariaLabel={`% ставки — ${c.Name}`}
borderless
/>
<span className={styles.percentSign}>%</span>
</div>
))}
</div>
</FormField>
</>
);
};
@@ -3,8 +3,8 @@ import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../..
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Условия оплаты и оформления" icon="calculator">
<FormField label="Испытательный срок" htmlFor="ProbationPeriod">
<CollapseSection title="4. Условия оплаты и оформления" icon="calculator">
<FormField label="4.1 Испытательный срок" htmlFor="ProbationPeriod">
<Select
id="ProbationPeriod"
value={data.ProbationPeriod}
@@ -13,21 +13,21 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
placeholder="— выберите —"
/>
</FormField>
<FormField label="Оклад при испытательном сроке" htmlFor="SalaryProbation">
<FormField label="4.2 Оклад при испытательном сроке" htmlFor="SalaryProbation">
<MoneyInput
id="SalaryProbation"
value={data.SalaryProbation}
onChange={(v) => set('SalaryProbation', v)}
/>
</FormField>
<FormField label="Оклад по завершении ИС" htmlFor="SalaryAfterProbation">
<FormField label="4.3 Оклад по завершении ИС" htmlFor="SalaryAfterProbation">
<MoneyInput
id="SalaryAfterProbation"
value={data.SalaryAfterProbation}
onChange={(v) => set('SalaryAfterProbation', v)}
/>
</FormField>
<FormField label="График работы" htmlFor="WorkSchedule">
<FormField label="4.4 График работы" htmlFor="WorkSchedule">
<Select
id="WorkSchedule"
value={data.WorkSchedule}
@@ -3,14 +3,14 @@ import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { ISectionProps } from '../model';
export const PpeSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="СИЗ (спецодежда)" icon="helmet">
<FormField label="Рост, см" htmlFor="PpeHeight">
<CollapseSection title="3. СИЗ (спецодежда)" icon="helmet">
<FormField label="3.1 Рост, см" htmlFor="PpeHeight">
<TextInput id="PpeHeight" type="number" value={data.PpeHeight} onChange={(v) => set('PpeHeight', v)} />
</FormField>
<FormField label="Размер одежды" htmlFor="PpeClothingSize">
<FormField label="3.2 Размер одежды" htmlFor="PpeClothingSize">
<TextInput id="PpeClothingSize" value={data.PpeClothingSize} onChange={(v) => set('PpeClothingSize', v)} />
</FormField>
<FormField label="Размер обуви" htmlFor="PpeShoeSize">
<FormField label="3.3 Размер обуви" htmlFor="PpeShoeSize">
<TextInput id="PpeShoeSize" type="number" value={data.PpeShoeSize} onChange={(v) => set('PpeShoeSize', v)} />
</FormField>
</CollapseSection>
@@ -29,6 +29,11 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
item.CompaniesJson = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
return;
}
if (key === 'CompanyShares') {
// % ставки по компаниям (совмещение) — таблица, сериализуем в Note.
item.CompanySharesJson = data.CompanyShares.length > 0 ? JSON.stringify(data.CompanyShares) : null;
return;
}
const value = data[key];
if (NUMBER_FIELDS.indexOf(key) !== -1) {
item[key] = value === '' ? null : Number(value);