fix: внес изменения в заявках рекрутинга от 16.09.26
Tests / frontend-test (push) Successful in 1m20s
Tests / frontend-test (push) Successful in 1m20s
This commit is contained in:
@@ -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>
|
||||
|
||||
+43
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user