fix: изменил заявку на подбор согласно требованиям от 17.08.2026
Tests / frontend-test (push) Successful in 1m8s
Tests / frontend-test (push) Successful in 1m8s
This commit is contained in:
@@ -108,9 +108,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim());
|
const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim() && data.Residence);
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (!isValid) return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность.';
|
if (!isValid) return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность, проживание.';
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { ICompany } from '../../shared/dictionary';
|
||||||
import { dataListTitle } from '../../shared/listTitles';
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, ILanguageLevel } from './model';
|
||||||
|
|
||||||
|
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
||||||
|
function parseJsonArray<T>(raw: unknown): T[] {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Категории полей — определяют, как читать значение из элемента списка.
|
// Категории полей — определяют, как читать значение из элемента списка.
|
||||||
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
||||||
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
||||||
const BOOL_FIELDS: (keyof IFormData)[] = ['IsRegistered', 'IsApproved', 'CompletionMark', 'MaterialResponsibility'];
|
const BOOL_FIELDS: (keyof IFormData)[] = [
|
||||||
|
'MaterialResponsibility',
|
||||||
|
'PieceworkPay',
|
||||||
|
'ReferralProgram',
|
||||||
|
'BusinessTrips'
|
||||||
|
];
|
||||||
const DATE_FIELDS: (keyof IFormData)[] = ['SubmitDate', 'ClosingDeadline'];
|
const DATE_FIELDS: (keyof IFormData)[] = ['SubmitDate', 'ClosingDeadline'];
|
||||||
|
|
||||||
type PersonValue = { EMail?: string } | undefined;
|
type PersonValue = { EMail?: string } | undefined;
|
||||||
@@ -23,21 +40,39 @@ export async function loadSelectionRequest(itemId: number): Promise<ILoadedReque
|
|||||||
const isPerson = (k: keyof IFormData): boolean =>
|
const isPerson = (k: keyof IFormData): boolean =>
|
||||||
PERSON_FIELDS.indexOf(k) !== -1 || MULTI_PERSON_FIELDS.indexOf(k) !== -1;
|
PERSON_FIELDS.indexOf(k) !== -1 || MULTI_PERSON_FIELDS.indexOf(k) !== -1;
|
||||||
|
|
||||||
// Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand).
|
// Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand);
|
||||||
const scalarSelects = keys.filter((k) => !isPerson(k));
|
// Companies/Languages/Programs хранятся в Note-колонках *Json (не одноимёнными колонками).
|
||||||
|
const jsonKeys: (keyof IFormData)[] = ['Companies', 'Languages', 'Programs', 'WorkplaceProvision'];
|
||||||
|
const scalarSelects = keys.filter((k) => !isPerson(k) && jsonKeys.indexOf(k) === -1);
|
||||||
const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`);
|
const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`);
|
||||||
const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS];
|
const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS];
|
||||||
|
|
||||||
const item = (await getSP()
|
const item = (await getSP()
|
||||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||||
.items.getById(itemId)
|
.items.getById(itemId)
|
||||||
.select(...scalarSelects, 'RequestStatus', 'RequestUniqueCode', ...personSelects)
|
.select(
|
||||||
|
...scalarSelects,
|
||||||
|
'CompaniesJson',
|
||||||
|
'LanguagesJson',
|
||||||
|
'ProgramsJson',
|
||||||
|
'WorkplaceProvisionJson',
|
||||||
|
'RequestStatus',
|
||||||
|
'RequestUniqueCode',
|
||||||
|
...personSelects
|
||||||
|
)
|
||||||
.expand(...expands)()) as Record<string, unknown>;
|
.expand(...expands)()) as Record<string, unknown>;
|
||||||
|
|
||||||
const data: IFormData = { ...EMPTY_DATA };
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
const writable = data as unknown as Record<string, unknown>;
|
const writable = data as unknown as Record<string, unknown>;
|
||||||
|
writable.Companies = parseJsonArray<ICompany>(item.CompaniesJson);
|
||||||
|
writable.Languages = parseJsonArray<ILanguageLevel>(item.LanguagesJson);
|
||||||
|
writable.Programs = parseJsonArray<string>(item.ProgramsJson);
|
||||||
|
writable.WorkplaceProvision = parseJsonArray<string>(item.WorkplaceProvisionJson);
|
||||||
|
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
|
if (jsonKeys.indexOf(key) !== -1) {
|
||||||
|
return; // уже разобрали из *Json
|
||||||
|
}
|
||||||
if (PERSON_FIELDS.indexOf(key) !== -1) {
|
if (PERSON_FIELDS.indexOf(key) !== -1) {
|
||||||
writable[key] = (item[key] as PersonValue)?.EMail || '';
|
writable[key] = (item[key] as PersonValue)?.EMail || '';
|
||||||
} else if (MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
} else if (MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { ISelectOption } from '../../shared/ui';
|
import { ISelectOption } from '../../shared/ui';
|
||||||
|
import { ICompany } from '../../shared/dictionary';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,47 +19,47 @@ export interface IFormData {
|
|||||||
SubmitDate: string;
|
SubmitDate: string;
|
||||||
ClosingDeadline: string;
|
ClosingDeadline: string;
|
||||||
OpenReason: string;
|
OpenReason: string;
|
||||||
IsRegistered: boolean;
|
OpenReasonDetails: string;
|
||||||
IsApproved: boolean;
|
Companies: ICompany[];
|
||||||
CompletionMark: boolean;
|
ObjectProject: string;
|
||||||
|
ObjectProjectId: string;
|
||||||
|
ObjectProjectCode: string;
|
||||||
// Отдел
|
// Отдел
|
||||||
DepartmentName: string;
|
DepartmentName: string;
|
||||||
DepartmentHeadcount: string;
|
OpenVacanciesCount: string;
|
||||||
DepartmentGoals: string;
|
|
||||||
RelatedDepartments: string;
|
|
||||||
// Вакансия
|
// Вакансия
|
||||||
Position: string;
|
Position: string;
|
||||||
ClassifierCode: string;
|
|
||||||
EmploymentType: string;
|
EmploymentType: string;
|
||||||
MainFunction: string;
|
JobFunction: string;
|
||||||
AdditionalFunction: string;
|
|
||||||
Competencies: string;
|
Competencies: string;
|
||||||
PersonalTraits: string;
|
PersonalTraits: string;
|
||||||
Education: string;
|
Education: string;
|
||||||
WorkExperience: string;
|
WorkExperience: string;
|
||||||
AdditionalEducation: string;
|
Programs: string[];
|
||||||
EvaluationCriteria: string;
|
Languages: ILanguageLevel[];
|
||||||
PcSkills: string;
|
|
||||||
ForeignLanguages: string;
|
|
||||||
Residence: string;
|
Residence: string;
|
||||||
|
ResidenceOther: string;
|
||||||
DrivingLicense: string;
|
DrivingLicense: string;
|
||||||
// Здоровье / командировки
|
// Командировки
|
||||||
HealthRestrictions: string;
|
BusinessTrips: boolean;
|
||||||
BusinessTrips: string;
|
BusinessTripsDetails: string;
|
||||||
|
BusinessTripsDetailsOther: string;
|
||||||
// Условия работы
|
// Условия работы
|
||||||
RateProbation: string;
|
RateProbation: string;
|
||||||
RateAfterProbation: string;
|
RateAfterProbation: string;
|
||||||
Awards: string;
|
Awards: string;
|
||||||
Bonuses: string;
|
Bonuses: string;
|
||||||
CompensationPackage: string;
|
CompensationPackage: string;
|
||||||
|
PieceworkPay: boolean;
|
||||||
WorkSchedule: string;
|
WorkSchedule: string;
|
||||||
Location: string;
|
WorkScheduleOther: string;
|
||||||
|
WorkHours: string;
|
||||||
|
WorkHoursOther: string;
|
||||||
MaterialResponsibility: boolean;
|
MaterialResponsibility: boolean;
|
||||||
WorkplaceProvision: string;
|
WorkplaceProvision: string[];
|
||||||
ProposedMentor: string;
|
ProposedMentor: string;
|
||||||
DirectManager: string;
|
DirectManager: string;
|
||||||
DevelopmentPrograms: string;
|
ReferralProgram: boolean;
|
||||||
DesiredTraining: string;
|
|
||||||
HiringDecisionMaker: string[];
|
HiringDecisionMaker: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,43 +78,43 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
SubmitDate: '',
|
SubmitDate: '',
|
||||||
ClosingDeadline: '',
|
ClosingDeadline: '',
|
||||||
OpenReason: '',
|
OpenReason: '',
|
||||||
IsRegistered: false,
|
OpenReasonDetails: '',
|
||||||
IsApproved: false,
|
Companies: [],
|
||||||
CompletionMark: false,
|
ObjectProject: '',
|
||||||
|
ObjectProjectId: '',
|
||||||
|
ObjectProjectCode: '',
|
||||||
DepartmentName: '',
|
DepartmentName: '',
|
||||||
DepartmentHeadcount: '',
|
OpenVacanciesCount: '',
|
||||||
DepartmentGoals: '',
|
|
||||||
RelatedDepartments: '',
|
|
||||||
Position: '',
|
Position: '',
|
||||||
ClassifierCode: '',
|
|
||||||
EmploymentType: '',
|
EmploymentType: '',
|
||||||
MainFunction: '',
|
JobFunction: '',
|
||||||
AdditionalFunction: '',
|
|
||||||
Competencies: '',
|
Competencies: '',
|
||||||
PersonalTraits: '',
|
PersonalTraits: '',
|
||||||
Education: '',
|
Education: '',
|
||||||
WorkExperience: '',
|
WorkExperience: '',
|
||||||
AdditionalEducation: '',
|
Programs: [],
|
||||||
EvaluationCriteria: '',
|
Languages: [],
|
||||||
PcSkills: '',
|
|
||||||
ForeignLanguages: '',
|
|
||||||
Residence: '',
|
Residence: '',
|
||||||
|
ResidenceOther: '',
|
||||||
DrivingLicense: '',
|
DrivingLicense: '',
|
||||||
HealthRestrictions: '',
|
BusinessTrips: false,
|
||||||
BusinessTrips: '',
|
BusinessTripsDetails: '',
|
||||||
|
BusinessTripsDetailsOther: '',
|
||||||
RateProbation: '',
|
RateProbation: '',
|
||||||
RateAfterProbation: '',
|
RateAfterProbation: '',
|
||||||
Awards: '',
|
Awards: '',
|
||||||
Bonuses: '',
|
Bonuses: '',
|
||||||
CompensationPackage: '',
|
CompensationPackage: '',
|
||||||
|
PieceworkPay: false,
|
||||||
WorkSchedule: '',
|
WorkSchedule: '',
|
||||||
Location: '',
|
WorkScheduleOther: '',
|
||||||
|
WorkHours: '',
|
||||||
|
WorkHoursOther: '',
|
||||||
MaterialResponsibility: false,
|
MaterialResponsibility: false,
|
||||||
WorkplaceProvision: '',
|
WorkplaceProvision: [],
|
||||||
ProposedMentor: '',
|
ProposedMentor: '',
|
||||||
DirectManager: '',
|
DirectManager: '',
|
||||||
DevelopmentPrograms: '',
|
ReferralProgram: false,
|
||||||
DesiredTraining: '',
|
|
||||||
HiringDecisionMaker: []
|
HiringDecisionMaker: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,5 +124,127 @@ export const EMPLOYMENT_TYPE_OPTIONS: ISelectOption[] = [
|
|||||||
{ value: 'Временная', label: 'Временная' }
|
{ value: 'Временная', label: 'Временная' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */
|
||||||
|
export const OTHER_VALUE = 'Другое';
|
||||||
|
|
||||||
|
/** Причины открытия вакансии, требующие ФИО сотрудника (для условного поля). */
|
||||||
|
export const OPEN_REASON_REPLACE = 'Замена уволенного сотрудника';
|
||||||
|
export const OPEN_REASON_TRANSFER = 'Перевод сотрудника';
|
||||||
|
export const OPEN_REASON_NEW = 'Новая штатная единица';
|
||||||
|
|
||||||
|
/** Варианты «Причина открытия вакансии» — совпадают с choices колонки OpenReason. */
|
||||||
|
export const OPEN_REASON_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE },
|
||||||
|
{ value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER },
|
||||||
|
{ value: OPEN_REASON_NEW, label: OPEN_REASON_NEW },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Проживание» — совпадают с choices колонки Residence. */
|
||||||
|
export const RESIDENCE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Общежитие компании', label: 'Общежитие компании' },
|
||||||
|
{ value: 'За счёт компании', label: 'За счёт компании' },
|
||||||
|
{ value: 'За счёт кандидата', label: 'За счёт кандидата' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Владение одним языком: язык + требуемый уровень. Хранится JSON-массивом. */
|
||||||
|
export interface ILanguageLevel {
|
||||||
|
language: string;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иностранные языки, актуальные для Казахстана (фикс. набор строк таблицы). */
|
||||||
|
export const LANGUAGES: string[] = ['Казахский', 'Русский', 'Английский', 'Китайский', 'Турецкий'];
|
||||||
|
|
||||||
|
/** Шкала уровней владения языком. */
|
||||||
|
export const LANGUAGE_LEVEL_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Начинающий', label: 'Начинающий' },
|
||||||
|
{ value: 'Базовый', label: 'Базовый' },
|
||||||
|
{ value: 'Средний', label: 'Средний' },
|
||||||
|
{ value: 'Продвинутый', label: 'Продвинутый' },
|
||||||
|
{ value: 'Свободный', label: 'Свободный' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Программы (ПО) — ВРЕМЕННЫЙ список, пока нет справочника в API. Используется в
|
||||||
|
* мультивыборе «Необходимые знания в программах». Меняется здесь одним местом.
|
||||||
|
*/
|
||||||
|
export const SOFTWARE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: '1С:Предприятие', label: '1С:Предприятие' },
|
||||||
|
{ value: 'MS Word', label: 'MS Word' },
|
||||||
|
{ value: 'MS Excel', label: 'MS Excel' },
|
||||||
|
{ value: 'MS PowerPoint', label: 'MS PowerPoint' },
|
||||||
|
{ value: 'MS Outlook', label: 'MS Outlook' },
|
||||||
|
{ value: 'MS Project', label: 'MS Project' },
|
||||||
|
{ value: 'AutoCAD', label: 'AutoCAD' },
|
||||||
|
{ value: 'Revit', label: 'Revit' },
|
||||||
|
{ value: 'ArchiCAD', label: 'ArchiCAD' },
|
||||||
|
{ value: 'Компас-3D', label: 'Компас-3D' },
|
||||||
|
{ value: 'Primavera', label: 'Primavera' },
|
||||||
|
{ value: 'SAP', label: 'SAP' },
|
||||||
|
{ value: 'Adobe Photoshop', label: 'Adobe Photoshop' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Категории «Обеспечение рабочего места» (+ «Другое» через allowOther). */
|
||||||
|
export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Оргтехника', label: 'Оргтехника' },
|
||||||
|
{ value: 'СИЗ', label: 'СИЗ' },
|
||||||
|
{ value: 'Программное обеспечение', label: 'Программное обеспечение' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Командировки — продолжительность/частота» — совпадают с choices BusinessTripsDetails. */
|
||||||
|
export const BUSINESS_TRIP_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Редко (1–2 раза в год)', label: 'Редко (1–2 раза в год)' },
|
||||||
|
{ value: 'Ежеквартально', label: 'Ежеквартально' },
|
||||||
|
{ value: 'Ежемесячно', label: 'Ежемесячно' },
|
||||||
|
{ value: 'Еженедельно', label: 'Еженедельно' },
|
||||||
|
{ value: 'Постоянные разъезды', label: 'Постоянные разъезды' },
|
||||||
|
{ value: 'Вахтовый метод', label: 'Вахтовый метод' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Образование» (РК) — совпадают с choices колонки Education. */
|
||||||
|
export const EDUCATION_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Среднее', label: 'Среднее' },
|
||||||
|
{ value: 'Техническое и профессиональное (ТиПО)', label: 'Техническое и профессиональное (ТиПО)' },
|
||||||
|
{ value: 'Незаконченное высшее', label: 'Незаконченное высшее' },
|
||||||
|
{ value: 'Высшее — бакалавриат', label: 'Высшее — бакалавриат' },
|
||||||
|
{ value: 'Высшее — магистратура', label: 'Высшее — магистратура' },
|
||||||
|
{ value: 'Послевузовское (докторантура / PhD)', label: 'Послевузовское (докторантура / PhD)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Опыт работы» — совпадают с choices колонки WorkExperience. */
|
||||||
|
export const WORK_EXPERIENCE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Без опыта', label: 'Без опыта' },
|
||||||
|
{ value: 'До 3 лет', label: 'До 3 лет' },
|
||||||
|
{ value: 'От 3 до 5 лет', label: 'От 3 до 5 лет' },
|
||||||
|
{ value: 'От 5 лет и выше', label: 'От 5 лет и выше' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Режим работы» — совпадают с choices колонки WorkSchedule. */
|
||||||
|
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: '5/2', label: '5/2' },
|
||||||
|
{ value: 'Вахтовый 30/30', label: 'Вахтовый 30/30' },
|
||||||
|
{ value: '20/10', label: '20/10' },
|
||||||
|
{ value: '30/15', label: '30/15' },
|
||||||
|
{ value: '22/8', label: '22/8' },
|
||||||
|
{ value: '15/15', label: '15/15' },
|
||||||
|
{ value: '6/1', label: '6/1' },
|
||||||
|
{ value: '1/2', label: '1/2' },
|
||||||
|
{ value: '2/2', label: '2/2' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Варианты «Время работы» — совпадают с choices колонки WorkHours. */
|
||||||
|
export const WORK_HOURS_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: '8-5', label: '8-5' },
|
||||||
|
{ value: '9-6', label: '9-6' },
|
||||||
|
{ value: '7-7', label: '7-7' },
|
||||||
|
{ value: '8-8', label: '8-8' },
|
||||||
|
{ value: '9-9', label: '9-9' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
/** Плейсхолдер для полей-людей. */
|
/** Плейсхолдер для полей-людей. */
|
||||||
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
||||||
|
|||||||
@@ -5,49 +5,49 @@
|
|||||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" },
|
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] },
|
||||||
{ "internalName": "IsApproved", "title": "Одобрена", "type": "Boolean" },
|
{ "internalName": "OpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
|
||||||
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Note" },
|
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
|
||||||
|
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
|
||||||
|
{ "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" },
|
||||||
|
{ "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" },
|
||||||
{ "internalName": "ClosingDeadline", "title": "Крайний срок закрытия", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "ClosingDeadline", "title": "Крайний срок закрытия", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "CompletionMark", "title": "Отметка о выполнении", "type": "Boolean" },
|
|
||||||
|
|
||||||
{ "internalName": "DepartmentName", "title": "Название отдела/департамента", "type": "Text", "required": true },
|
{ "internalName": "DepartmentName", "title": "Название отдела/департамента", "type": "Text", "required": true },
|
||||||
{ "internalName": "DepartmentHeadcount", "title": "Количество сотрудников в отделе", "type": "Number" },
|
{ "internalName": "OpenVacanciesCount", "title": "Количество открытых вакансий", "type": "Number" },
|
||||||
{ "internalName": "DepartmentGoals", "title": "Цели, стоящие перед отделом", "type": "Note" },
|
|
||||||
{ "internalName": "RelatedDepartments", "title": "Смежные отделы по бизнес-процессам", "type": "Note" },
|
|
||||||
|
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
||||||
{ "internalName": "ClassifierCode", "title": "Код по классификатору", "type": "Text" },
|
|
||||||
{ "internalName": "EmploymentType", "title": "Характер занятости", "type": "Choice", "choices": ["Постоянная", "Временная"] },
|
{ "internalName": "EmploymentType", "title": "Характер занятости", "type": "Choice", "choices": ["Постоянная", "Временная"] },
|
||||||
{ "internalName": "MainFunction", "title": "Основной функционал", "type": "Note" },
|
{ "internalName": "JobFunction", "title": "Функционал", "type": "Note" },
|
||||||
{ "internalName": "AdditionalFunction", "title": "Дополнительный функционал", "type": "Note" },
|
|
||||||
{ "internalName": "Competencies", "title": "Компетенции", "type": "Note" },
|
{ "internalName": "Competencies", "title": "Компетенции", "type": "Note" },
|
||||||
{ "internalName": "PersonalTraits", "title": "Личностные характеристики", "type": "Note" },
|
{ "internalName": "PersonalTraits", "title": "Личностные характеристики", "type": "Note" },
|
||||||
{ "internalName": "Education", "title": "Образование", "type": "Text" },
|
{ "internalName": "Education", "title": "Образование", "type": "Choice", "choices": ["Среднее", "Техническое и профессиональное (ТиПО)", "Незаконченное высшее", "Высшее — бакалавриат", "Высшее — магистратура", "Послевузовское (докторантура / PhD)"] },
|
||||||
{ "internalName": "WorkExperience", "title": "Опыт работы", "type": "Note" },
|
{ "internalName": "WorkExperience", "title": "Опыт работы", "type": "Choice", "choices": ["Без опыта", "До 3 лет", "От 3 до 5 лет", "От 5 лет и выше"] },
|
||||||
{ "internalName": "AdditionalEducation", "title": "Дополнительное образование", "type": "Note" },
|
{ "internalName": "ProgramsJson", "title": "Необходимые знания в программах (JSON)", "type": "Note" },
|
||||||
{ "internalName": "EvaluationCriteria", "title": "Критерии оценки работы", "type": "Note" },
|
{ "internalName": "LanguagesJson", "title": "Иностранные языки (JSON)", "type": "Note" },
|
||||||
{ "internalName": "PcSkills", "title": "Уровень владения ПК и программами", "type": "Note" },
|
{ "internalName": "Residence", "title": "Проживание", "type": "Choice", "required": true, "choices": ["Общежитие компании", "За счёт компании", "За счёт кандидата", "Другое"] },
|
||||||
{ "internalName": "ForeignLanguages", "title": "Иностранные языки (уровень владения)", "type": "Note" },
|
{ "internalName": "ResidenceOther", "title": "Проживание (другое)", "type": "Text" },
|
||||||
{ "internalName": "Residence", "title": "Проживание", "type": "Text" },
|
|
||||||
{ "internalName": "DrivingLicense", "title": "Водительские права, стаж вождения", "type": "Text" },
|
{ "internalName": "DrivingLicense", "title": "Водительские права, стаж вождения", "type": "Text" },
|
||||||
|
|
||||||
{ "internalName": "HealthRestrictions", "title": "Приемлемые противопоказания (здоровье)", "type": "Note" },
|
{ "internalName": "BusinessTrips", "title": "Командировки", "type": "Boolean" },
|
||||||
{ "internalName": "BusinessTrips", "title": "Командировки (есть/нет и продолжительность)", "type": "Note" },
|
{ "internalName": "BusinessTripsDetails", "title": "Командировки — продолжительность / частота", "type": "Choice", "choices": ["Редко (1–2 раза в год)", "Ежеквартально", "Ежемесячно", "Еженедельно", "Постоянные разъезды", "Вахтовый метод", "Другое"] },
|
||||||
|
{ "internalName": "BusinessTripsDetailsOther", "title": "Командировки — частота (другое)", "type": "Text" },
|
||||||
|
|
||||||
{ "internalName": "RateProbation", "title": "Ставка на ИС", "type": "Number", "decimals": 2 },
|
{ "internalName": "RateProbation", "title": "Ставка на ИС", "type": "Number", "decimals": 2 },
|
||||||
{ "internalName": "RateAfterProbation", "title": "Ставка после ИС", "type": "Number", "decimals": 2 },
|
{ "internalName": "RateAfterProbation", "title": "Ставка после ИС", "type": "Number", "decimals": 2 },
|
||||||
{ "internalName": "Awards", "title": "Премии", "type": "Note" },
|
{ "internalName": "Awards", "title": "Премии", "type": "Note" },
|
||||||
{ "internalName": "Bonuses", "title": "Бонусы", "type": "Note" },
|
{ "internalName": "Bonuses", "title": "Бонусы", "type": "Note" },
|
||||||
{ "internalName": "CompensationPackage", "title": "Компенсационный пакет", "type": "Note" },
|
{ "internalName": "CompensationPackage", "title": "Компенсационный пакет", "type": "Note" },
|
||||||
{ "internalName": "WorkSchedule", "title": "Режим работы", "type": "Text" },
|
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
|
||||||
{ "internalName": "Location", "title": "Локация", "type": "Text" },
|
{ "internalName": "WorkSchedule", "title": "Режим работы", "type": "Choice", "choices": ["5/2", "Вахтовый 30/30", "20/10", "30/15", "22/8", "15/15", "6/1", "1/2", "2/2", "Другое"] },
|
||||||
|
{ "internalName": "WorkScheduleOther", "title": "Режим работы (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "WorkHours", "title": "Время работы", "type": "Choice", "choices": ["8-5", "9-6", "7-7", "8-8", "9-9", "Другое"] },
|
||||||
|
{ "internalName": "WorkHoursOther", "title": "Время работы (другое)", "type": "Text" },
|
||||||
{ "internalName": "MaterialResponsibility", "title": "Материальная ответственность", "type": "Boolean" },
|
{ "internalName": "MaterialResponsibility", "title": "Материальная ответственность", "type": "Boolean" },
|
||||||
{ "internalName": "WorkplaceProvision", "title": "Обеспечение рабочего места", "type": "Note" },
|
{ "internalName": "WorkplaceProvisionJson", "title": "Обеспечение рабочего места (JSON)", "type": "Note" },
|
||||||
{ "internalName": "ProposedMentor", "title": "Предполагаемый наставник", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "ProposedMentor", "title": "Предполагаемый наставник", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "DirectManager", "title": "Непосредственный руководитель", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "DirectManager", "title": "Непосредственный руководитель", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "DevelopmentPrograms", "title": "Программы развития и обучения, периодичность", "type": "Note" },
|
{ "internalName": "ReferralProgram", "title": "Реферальная программа", "type": "Boolean" },
|
||||||
{ "internalName": "DesiredTraining", "title": "Желаемое доп. обучение при выходе на работу", "type": "Note" },
|
|
||||||
{ "internalName": "HiringDecisionMaker", "title": "Кто принимает решение о приеме (собеседования)", "type": "User", "userSelectionMode": "PeopleOnly", "allowMultiple": true }
|
{ "internalName": "HiringDecisionMaker", "title": "Кто принимает решение о приеме (собеседования)", "type": "User", "userSelectionMode": "PeopleOnly", "allowMultiple": true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-37
@@ -1,39 +1,85 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, Toggle, PeopleField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, Select, PeopleField } from '../../../shared/ui';
|
||||||
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
|
import { CompanyPickerMulti, Project1CPicker } from '../../../shared/dictionary';
|
||||||
|
import {
|
||||||
|
ISectionProps,
|
||||||
|
PERSON_PLACEHOLDER,
|
||||||
|
OPEN_REASON_OPTIONS,
|
||||||
|
OPEN_REASON_REPLACE,
|
||||||
|
OPEN_REASON_TRANSFER,
|
||||||
|
OPEN_REASON_NEW
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => {
|
||||||
<CollapseSection title="Заявка" icon="clipboard">
|
const reason = data.OpenReason;
|
||||||
<FormField label="Ответственный за подачу заявки" required full>
|
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
|
||||||
<PeopleField
|
const reasonDetailsLabel =
|
||||||
context={context}
|
reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину';
|
||||||
value={data.Responsible}
|
|
||||||
onChange={(v) => set('Responsible', v)}
|
return (
|
||||||
placeholder={PERSON_PLACEHOLDER}
|
<CollapseSection title="1. Заявка" icon="clipboard">
|
||||||
/>
|
<FormField label="1.1 Ответственный за подачу заявки" required full>
|
||||||
</FormField>
|
<PeopleField
|
||||||
<FormField label="Дата подачи заявки" htmlFor="SubmitDate">
|
context={context}
|
||||||
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
|
value={data.Responsible}
|
||||||
</FormField>
|
onChange={(v) => set('Responsible', v)}
|
||||||
<FormField label="Крайний срок закрытия" htmlFor="ClosingDeadline">
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
<TextInput
|
/>
|
||||||
id="ClosingDeadline"
|
</FormField>
|
||||||
type="date"
|
<FormField label="1.2 Дата подачи заявки" htmlFor="SubmitDate">
|
||||||
value={data.ClosingDeadline}
|
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
|
||||||
onChange={(v) => set('ClosingDeadline', v)}
|
</FormField>
|
||||||
/>
|
<FormField label="1.3 Крайний срок закрытия" htmlFor="ClosingDeadline">
|
||||||
</FormField>
|
<TextInput
|
||||||
<FormField label="Причина открытия вакансии" htmlFor="OpenReason" full>
|
id="ClosingDeadline"
|
||||||
<TextArea id="OpenReason" value={data.OpenReason} onChange={(v) => set('OpenReason', v)} placeholder="..." />
|
type="date"
|
||||||
</FormField>
|
value={data.ClosingDeadline}
|
||||||
<FormField label="Зарегистрирована">
|
onChange={(v) => set('ClosingDeadline', v)}
|
||||||
<Toggle checked={data.IsRegistered} onChange={(v) => set('IsRegistered', v)} onLabel="Да" />
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Одобрена">
|
<FormField label="1.4 Причина открытия вакансии" htmlFor="OpenReason">
|
||||||
<Toggle checked={data.IsApproved} onChange={(v) => set('IsApproved', v)} onLabel="Да" />
|
<Select
|
||||||
</FormField>
|
id="OpenReason"
|
||||||
<FormField label="Отметка о выполнении">
|
value={data.OpenReason}
|
||||||
<Toggle checked={data.CompletionMark} onChange={(v) => set('CompletionMark', v)} onLabel="Да" />
|
onChange={(v) => {
|
||||||
</FormField>
|
set('OpenReason', v);
|
||||||
</CollapseSection>
|
if (v === '' || v === OPEN_REASON_NEW) set('OpenReasonDetails', '');
|
||||||
);
|
}}
|
||||||
|
options={OPEN_REASON_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{showReasonDetails && (
|
||||||
|
<FormField label={reasonDetailsLabel} htmlFor="OpenReasonDetails">
|
||||||
|
<TextInput
|
||||||
|
id="OpenReasonDetails"
|
||||||
|
value={data.OpenReasonDetails}
|
||||||
|
onChange={(v) => set('OpenReasonDetails', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="1.5 Компания" htmlFor="Companies" full>
|
||||||
|
<CompanyPickerMulti
|
||||||
|
context={context}
|
||||||
|
id="Companies"
|
||||||
|
value={data.Companies}
|
||||||
|
onChange={(v) => set('Companies', v)}
|
||||||
|
placeholder="Начните вводить название компании…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="1.6 Объект/Проект" htmlFor="ObjectProject" full>
|
||||||
|
<Project1CPicker
|
||||||
|
context={context}
|
||||||
|
id="ObjectProject"
|
||||||
|
value={data.ObjectProject}
|
||||||
|
onChange={(name, id, code) => {
|
||||||
|
set('ObjectProject', name);
|
||||||
|
set('ObjectProjectId', id);
|
||||||
|
set('ObjectProjectCode', code);
|
||||||
|
}}
|
||||||
|
placeholder="Начните вводить название или код проекта…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+70
-24
@@ -1,45 +1,97 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, MoneyInput, Toggle, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, TextArea, MoneyInput, Toggle, Select, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
|
||||||
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
|
import { MultiChoiceField } from './MultiChoiceField';
|
||||||
|
import {
|
||||||
|
ISectionProps,
|
||||||
|
PERSON_PLACEHOLDER,
|
||||||
|
OTHER_VALUE,
|
||||||
|
WORK_SCHEDULE_OPTIONS,
|
||||||
|
WORK_HOURS_OPTIONS,
|
||||||
|
WORKPLACE_PROVISION_OPTIONS
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
<CollapseSection title="Условия работы" icon="chart">
|
<CollapseSection title="5. Условия работы" icon="chart">
|
||||||
<FormField label="Ставка на ИС" htmlFor="RateProbation">
|
<FormField label="5.1 Ставка на ИС" htmlFor="RateProbation">
|
||||||
<MoneyInput id="RateProbation" value={data.RateProbation} onChange={(v) => set('RateProbation', v)} />
|
<MoneyInput id="RateProbation" value={data.RateProbation} onChange={(v) => set('RateProbation', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Ставка после ИС" htmlFor="RateAfterProbation">
|
<FormField label="5.2 Ставка после ИС" htmlFor="RateAfterProbation">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
id="RateAfterProbation"
|
id="RateAfterProbation"
|
||||||
value={data.RateAfterProbation}
|
value={data.RateAfterProbation}
|
||||||
onChange={(v) => set('RateAfterProbation', v)}
|
onChange={(v) => set('RateAfterProbation', v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Премии" htmlFor="Awards" full>
|
<FormField label="5.3 Премии" htmlFor="Awards" full>
|
||||||
<TextArea id="Awards" value={data.Awards} onChange={(v) => set('Awards', v)} />
|
<TextArea id="Awards" value={data.Awards} onChange={(v) => set('Awards', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Бонусы" htmlFor="Bonuses" full>
|
<FormField label="5.4 Бонусы" htmlFor="Bonuses" full>
|
||||||
<TextArea id="Bonuses" value={data.Bonuses} onChange={(v) => set('Bonuses', v)} />
|
<TextArea id="Bonuses" value={data.Bonuses} onChange={(v) => set('Bonuses', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Компенсационный пакет" htmlFor="CompensationPackage" full>
|
<FormField label="5.5 Компенсационный пакет" htmlFor="CompensationPackage" full>
|
||||||
<TextArea id="CompensationPackage" value={data.CompensationPackage} onChange={(v) => set('CompensationPackage', v)} />
|
<TextArea id="CompensationPackage" value={data.CompensationPackage} onChange={(v) => set('CompensationPackage', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Режим работы" htmlFor="WorkSchedule">
|
<FormField label="5.6 Сдельная оплата труда">
|
||||||
<TextInput id="WorkSchedule" value={data.WorkSchedule} onChange={(v) => set('WorkSchedule', v)} />
|
<Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Локация" htmlFor="Location">
|
<FormField label="5.7 Режим работы" htmlFor="WorkSchedule">
|
||||||
<TextInput id="Location" value={data.Location} onChange={(v) => set('Location', v)} />
|
<Select
|
||||||
|
id="WorkSchedule"
|
||||||
|
value={data.WorkSchedule}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('WorkSchedule', v);
|
||||||
|
if (v !== OTHER_VALUE) set('WorkScheduleOther', '');
|
||||||
|
}}
|
||||||
|
options={WORK_SCHEDULE_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Материальная ответственность">
|
{data.WorkSchedule === OTHER_VALUE && (
|
||||||
|
<FormField label="Режим работы (другое)" htmlFor="WorkScheduleOther">
|
||||||
|
<TextInput
|
||||||
|
id="WorkScheduleOther"
|
||||||
|
value={data.WorkScheduleOther}
|
||||||
|
onChange={(v) => set('WorkScheduleOther', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="5.8 Время работы" htmlFor="WorkHours">
|
||||||
|
<Select
|
||||||
|
id="WorkHours"
|
||||||
|
value={data.WorkHours}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('WorkHours', v);
|
||||||
|
if (v !== OTHER_VALUE) set('WorkHoursOther', '');
|
||||||
|
}}
|
||||||
|
options={WORK_HOURS_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{data.WorkHours === OTHER_VALUE && (
|
||||||
|
<FormField label="Время работы (другое)" htmlFor="WorkHoursOther">
|
||||||
|
<TextInput id="WorkHoursOther" value={data.WorkHoursOther} onChange={(v) => set('WorkHoursOther', v)} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="5.9 Материальная ответственность">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={data.MaterialResponsibility}
|
checked={data.MaterialResponsibility}
|
||||||
onChange={(v) => set('MaterialResponsibility', v)}
|
onChange={(v) => set('MaterialResponsibility', v)}
|
||||||
onLabel="Да"
|
onLabel="Да"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Обеспечение рабочего места" htmlFor="WorkplaceProvision" full>
|
<FormField label="5.10 Обеспечение рабочего места" full>
|
||||||
<TextArea id="WorkplaceProvision" value={data.WorkplaceProvision} onChange={(v) => set('WorkplaceProvision', v)} />
|
<MultiChoiceField
|
||||||
|
value={data.WorkplaceProvision}
|
||||||
|
onChange={(v) => set('WorkplaceProvision', v)}
|
||||||
|
options={WORKPLACE_PROVISION_OPTIONS}
|
||||||
|
allowOther
|
||||||
|
placeholder="Выберите категорию"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Предполагаемый наставник">
|
<FormField label="5.11 Реферальная программа">
|
||||||
|
<Toggle checked={data.ReferralProgram} onChange={(v) => set('ReferralProgram', v)} onLabel="Да" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="5.12 Предполагаемый наставник">
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.ProposedMentor}
|
value={data.ProposedMentor}
|
||||||
@@ -47,7 +99,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
placeholder={PERSON_PLACEHOLDER}
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Непосредственный руководитель">
|
<FormField label="5.13 Непосредственный руководитель">
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.DirectManager}
|
value={data.DirectManager}
|
||||||
@@ -55,13 +107,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
placeholder={PERSON_PLACEHOLDER}
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Программы развития и обучения, периодичность" htmlFor="DevelopmentPrograms" full>
|
<FormField label="5.14 Кто принимает решение о приеме (собеседования)" full>
|
||||||
<TextArea id="DevelopmentPrograms" value={data.DevelopmentPrograms} onChange={(v) => set('DevelopmentPrograms', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Желаемое доп. обучение при выходе на работу" htmlFor="DesiredTraining" full>
|
|
||||||
<TextArea id="DesiredTraining" value={data.DesiredTraining} onChange={(v) => set('DesiredTraining', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Кто принимает решение о приеме (собеседования)" full>
|
|
||||||
<PeopleFieldMulti
|
<PeopleFieldMulti
|
||||||
context={context}
|
context={context}
|
||||||
value={data.HiringDecisionMaker}
|
value={data.HiringDecisionMaker}
|
||||||
|
|||||||
+7
-17
@@ -1,28 +1,18 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title="Отдел / Подразделение" icon="building">
|
<CollapseSection title="2. Отдел / Подразделение" icon="building">
|
||||||
<FormField label="Название отдела/департамента" htmlFor="DepartmentName" required full>
|
<FormField label="2.1 Название отдела/департамента" htmlFor="DepartmentName" required full>
|
||||||
<TextInput id="DepartmentName" value={data.DepartmentName} onChange={(v) => set('DepartmentName', v)} />
|
<TextInput id="DepartmentName" value={data.DepartmentName} onChange={(v) => set('DepartmentName', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Количество сотрудников в отделе" htmlFor="DepartmentHeadcount">
|
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="DepartmentHeadcount"
|
id="OpenVacanciesCount"
|
||||||
type="number"
|
type="number"
|
||||||
value={data.DepartmentHeadcount}
|
value={data.OpenVacanciesCount}
|
||||||
onChange={(v) => set('DepartmentHeadcount', v)}
|
onChange={(v) => set('OpenVacanciesCount', v !== '' && Number(v) < 0 ? '0' : v)}
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Цели, стоящие перед отделом" htmlFor="DepartmentGoals" full>
|
|
||||||
<TextArea id="DepartmentGoals" value={data.DepartmentGoals} onChange={(v) => set('DepartmentGoals', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Смежные отделы по бизнес-процессам" htmlFor="RelatedDepartments" full>
|
|
||||||
<TextArea
|
|
||||||
id="RelatedDepartments"
|
|
||||||
value={data.RelatedDepartments}
|
|
||||||
onChange={(v) => set('RelatedDepartments', v)}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|||||||
+28
-12
@@ -1,18 +1,34 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextArea } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, Select, Toggle } from '../../../shared/ui';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps, BUSINESS_TRIP_OPTIONS, OTHER_VALUE } from '../model';
|
||||||
|
|
||||||
export const HealthTripsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const HealthTripsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title="Здоровье и командировки" icon="send">
|
<CollapseSection title="4. Командировки" icon="send">
|
||||||
<FormField label="Приемлемые противопоказания (здоровье)" htmlFor="HealthRestrictions" full>
|
<FormField label="4.1 Командировки">
|
||||||
<TextArea
|
<Toggle checked={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} onLabel="Да" />
|
||||||
id="HealthRestrictions"
|
|
||||||
value={data.HealthRestrictions}
|
|
||||||
onChange={(v) => set('HealthRestrictions', v)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Командировки (есть/нет и продолжительность)" htmlFor="BusinessTrips" full>
|
|
||||||
<TextArea id="BusinessTrips" value={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{data.BusinessTrips && (
|
||||||
|
<FormField label="Продолжительность / частота" htmlFor="BusinessTripsDetails">
|
||||||
|
<Select
|
||||||
|
id="BusinessTripsDetails"
|
||||||
|
value={data.BusinessTripsDetails}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('BusinessTripsDetails', v);
|
||||||
|
if (v !== OTHER_VALUE) set('BusinessTripsDetailsOther', '');
|
||||||
|
}}
|
||||||
|
options={BUSINESS_TRIP_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{data.BusinessTrips && data.BusinessTripsDetails === OTHER_VALUE && (
|
||||||
|
<FormField label="Продолжительность / частота (другое)" htmlFor="BusinessTripsDetailsOther">
|
||||||
|
<TextInput
|
||||||
|
id="BusinessTripsDetailsOther"
|
||||||
|
value={data.BusinessTripsDetailsOther}
|
||||||
|
onChange={(v) => set('BusinessTripsDetailsOther', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-1;
|
||||||
|
margin-bottom: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $navy-50;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-200;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оба селекта тянутся; на узких экранах переносятся и занимают всю ширину.
|
||||||
|
.addField {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import styles from './LanguagesField.module.scss';
|
||||||
|
import { Select, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui';
|
||||||
|
import { ILanguageLevel, LANGUAGES, LANGUAGE_LEVEL_OPTIONS } from '../model';
|
||||||
|
|
||||||
|
export interface ILanguagesFieldProps {
|
||||||
|
value: ILanguageLevel[];
|
||||||
|
onChange: (langs: ILanguageLevel[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Иностранные языки»: строка добавления (язык + уровень → «Добавить») и список
|
||||||
|
* выбранных. Показываются только добавленные языки; уже выбранные исключаются из
|
||||||
|
* списка для добавления.
|
||||||
|
*/
|
||||||
|
export const LanguagesField: React.FC<ILanguagesFieldProps> = ({ value, onChange }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const [draftLang, setDraftLang] = useState<string>('');
|
||||||
|
const [draftLevel, setDraftLevel] = useState<string>('');
|
||||||
|
|
||||||
|
const used = new Set(value.map((l) => l.language));
|
||||||
|
const langOptions: ISelectOption[] = LANGUAGES.filter((l) => !used.has(l)).map((l) => ({ value: l, label: l }));
|
||||||
|
|
||||||
|
const add = (): void => {
|
||||||
|
if (!draftLang || !draftLevel) return;
|
||||||
|
onChange([...value, { language: draftLang, level: draftLevel }]);
|
||||||
|
setDraftLang('');
|
||||||
|
setDraftLevel('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (lang: string): void => onChange(value.filter((l) => l.language !== lang));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{value.map((l) => (
|
||||||
|
<div key={l.language} className={styles.row}>
|
||||||
|
<span className={styles.lang}>{l.language}</span>
|
||||||
|
<span className={styles.dash}>—</span>
|
||||||
|
<span className={styles.level}>{l.level}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.remove}
|
||||||
|
aria-label={`Убрать ${l.language}`}
|
||||||
|
title="Убрать"
|
||||||
|
onClick={() => remove(l.language)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && langOptions.length > 0 && (
|
||||||
|
<div className={styles.add}>
|
||||||
|
<div className={styles.addField}>
|
||||||
|
<Select value={draftLang} onChange={setDraftLang} options={langOptions} placeholder="Выберите язык" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.addField}>
|
||||||
|
<Select
|
||||||
|
value={draftLevel}
|
||||||
|
onChange={setDraftLevel}
|
||||||
|
options={LANGUAGE_LEVEL_OPTIONS}
|
||||||
|
placeholder="Уровень"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" onClick={add} disabled={!draftLang || !draftLevel}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import styles from './LanguagesField.module.scss';
|
||||||
|
import { Select, TextInput, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui';
|
||||||
|
import { OTHER_VALUE } from '../model';
|
||||||
|
|
||||||
|
export interface IMultiChoiceFieldProps {
|
||||||
|
value: string[];
|
||||||
|
onChange: (values: string[]) => void;
|
||||||
|
options: ISelectOption[];
|
||||||
|
/** Разрешить «Другое» с ручным вводом произвольного значения. */
|
||||||
|
allowOther?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мультивыбор из фиксированного списка: выбор значения → «Добавить», выбранные —
|
||||||
|
* чипами с удалением. Уже выбранные исключаются из списка. allowOther добавляет
|
||||||
|
* пункт «Другое» с полем ручного ввода. Переиспользует стили LanguagesField.
|
||||||
|
*/
|
||||||
|
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
allowOther,
|
||||||
|
placeholder
|
||||||
|
}) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const [draft, setDraft] = useState<string>('');
|
||||||
|
const [draftOther, setDraftOther] = useState<string>('');
|
||||||
|
|
||||||
|
const selected = new Set(value);
|
||||||
|
const available = options.filter((o) => !selected.has(o.value));
|
||||||
|
const addOptions: ISelectOption[] = allowOther ? [...available, { value: OTHER_VALUE, label: OTHER_VALUE }] : available;
|
||||||
|
|
||||||
|
const isOther = draft === OTHER_VALUE;
|
||||||
|
const pending = isOther ? draftOther.trim() : draft;
|
||||||
|
|
||||||
|
const add = (): void => {
|
||||||
|
if (!pending || selected.has(pending)) return;
|
||||||
|
onChange([...value, pending]);
|
||||||
|
setDraft('');
|
||||||
|
setDraftOther('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (v: string): void => onChange(value.filter((x) => x !== v));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{value.map((v) => (
|
||||||
|
<div key={v} className={styles.row}>
|
||||||
|
<span className={styles.lang}>{v}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.remove}
|
||||||
|
aria-label={`Убрать ${v}`}
|
||||||
|
title="Убрать"
|
||||||
|
onClick={() => remove(v)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && addOptions.length > 0 && (
|
||||||
|
<div className={styles.add}>
|
||||||
|
<div className={styles.addField}>
|
||||||
|
<Select
|
||||||
|
value={draft}
|
||||||
|
onChange={(v) => {
|
||||||
|
setDraft(v);
|
||||||
|
if (v !== OTHER_VALUE) setDraftOther('');
|
||||||
|
}}
|
||||||
|
options={addOptions}
|
||||||
|
placeholder={placeholder ?? '— выберите —'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isOther && (
|
||||||
|
<div className={styles.addField}>
|
||||||
|
<TextInput value={draftOther} onChange={setDraftOther} placeholder="Введите значение" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="secondary" onClick={add} disabled={!pending}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYMENT_TYPE_OPTIONS } from '../model';
|
import { LanguagesField } from './LanguagesField';
|
||||||
|
import { MultiChoiceField } from './MultiChoiceField';
|
||||||
|
import {
|
||||||
|
ISectionProps,
|
||||||
|
EMPLOYMENT_TYPE_OPTIONS,
|
||||||
|
EDUCATION_OPTIONS,
|
||||||
|
WORK_EXPERIENCE_OPTIONS,
|
||||||
|
RESIDENCE_OPTIONS,
|
||||||
|
SOFTWARE_OPTIONS,
|
||||||
|
OTHER_VALUE
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title="Вакансия" icon="document">
|
<CollapseSection title="3. Вакансия" icon="document">
|
||||||
<FormField label="Должность" htmlFor="Position" required full>
|
<FormField label="3.1 Должность" htmlFor="Position" required full>
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Код по классификатору" htmlFor="ClassifierCode">
|
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType">
|
||||||
<TextInput id="ClassifierCode" value={data.ClassifierCode} onChange={(v) => set('ClassifierCode', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Характер занятости" htmlFor="EmploymentType">
|
|
||||||
<Select
|
<Select
|
||||||
id="EmploymentType"
|
id="EmploymentType"
|
||||||
value={data.EmploymentType}
|
value={data.EmploymentType}
|
||||||
@@ -19,40 +26,63 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
|
|||||||
placeholder="— выберите —"
|
placeholder="— выберите —"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Основной функционал" htmlFor="MainFunction" full>
|
<FormField label="3.3 Функционал" htmlFor="JobFunction" full>
|
||||||
<TextArea id="MainFunction" value={data.MainFunction} onChange={(v) => set('MainFunction', v)} />
|
<TextArea id="JobFunction" value={data.JobFunction} onChange={(v) => set('JobFunction', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Дополнительный функционал" htmlFor="AdditionalFunction" full>
|
<FormField label="3.4 Компетенции" htmlFor="Competencies" full>
|
||||||
<TextArea id="AdditionalFunction" value={data.AdditionalFunction} onChange={(v) => set('AdditionalFunction', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Компетенции" htmlFor="Competencies" full>
|
|
||||||
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
|
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Личностные характеристики" htmlFor="PersonalTraits" full>
|
<FormField label="3.5 Личностные характеристики" htmlFor="PersonalTraits" full>
|
||||||
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
|
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Образование" htmlFor="Education">
|
<FormField label="3.6 Образование" htmlFor="Education">
|
||||||
<TextInput id="Education" value={data.Education} onChange={(v) => set('Education', v)} />
|
<Select
|
||||||
|
id="Education"
|
||||||
|
value={data.Education}
|
||||||
|
onChange={(v) => set('Education', v)}
|
||||||
|
options={EDUCATION_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Опыт работы" htmlFor="WorkExperience" full>
|
<FormField label="3.7 Опыт работы" htmlFor="WorkExperience">
|
||||||
<TextArea id="WorkExperience" value={data.WorkExperience} onChange={(v) => set('WorkExperience', v)} />
|
<Select
|
||||||
|
id="WorkExperience"
|
||||||
|
value={data.WorkExperience}
|
||||||
|
onChange={(v) => set('WorkExperience', v)}
|
||||||
|
options={WORK_EXPERIENCE_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Дополнительное образование" htmlFor="AdditionalEducation" full>
|
<FormField label="3.8 Необходимые знания в программах" full>
|
||||||
<TextArea id="AdditionalEducation" value={data.AdditionalEducation} onChange={(v) => set('AdditionalEducation', v)} />
|
<MultiChoiceField
|
||||||
|
value={data.Programs}
|
||||||
|
onChange={(v) => set('Programs', v)}
|
||||||
|
options={SOFTWARE_OPTIONS}
|
||||||
|
allowOther
|
||||||
|
placeholder="Выберите программу"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Критерии оценки работы" htmlFor="EvaluationCriteria" full>
|
<FormField label="3.9 Иностранные языки" full>
|
||||||
<TextArea id="EvaluationCriteria" value={data.EvaluationCriteria} onChange={(v) => set('EvaluationCriteria', v)} />
|
<LanguagesField value={data.Languages} onChange={(v) => set('Languages', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Уровень владения ПК и программами" htmlFor="PcSkills" full>
|
<FormField label="3.10 Проживание" htmlFor="Residence" required>
|
||||||
<TextArea id="PcSkills" value={data.PcSkills} onChange={(v) => set('PcSkills', v)} />
|
<Select
|
||||||
|
id="Residence"
|
||||||
|
value={data.Residence}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('Residence', v);
|
||||||
|
if (v !== OTHER_VALUE) set('ResidenceOther', '');
|
||||||
|
}}
|
||||||
|
options={RESIDENCE_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Иностранные языки (уровень владения)" htmlFor="ForeignLanguages" full>
|
{data.Residence === OTHER_VALUE && (
|
||||||
<TextArea id="ForeignLanguages" value={data.ForeignLanguages} onChange={(v) => set('ForeignLanguages', v)} />
|
<FormField label="Проживание (другое)" htmlFor="ResidenceOther">
|
||||||
</FormField>
|
<TextInput id="ResidenceOther" value={data.ResidenceOther} onChange={(v) => set('ResidenceOther', v)} />
|
||||||
<FormField label="Проживание" htmlFor="Residence">
|
</FormField>
|
||||||
<TextInput id="Residence" value={data.Residence} onChange={(v) => set('Residence', v)} />
|
)}
|
||||||
</FormField>
|
<FormField label="3.11 Водительские права, стаж вождения" htmlFor="DrivingLicense">
|
||||||
<FormField label="Водительские права, стаж вождения" htmlFor="DrivingLicense">
|
|
||||||
<TextInput id="DrivingLicense" value={data.DrivingLicense} onChange={(v) => set('DrivingLicense', v)} />
|
<TextInput id="DrivingLicense" value={data.DrivingLicense} onChange={(v) => set('DrivingLicense', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'Di
|
|||||||
// Поля-люди (несколько): логины → массив Id в колонку <Field>Id.
|
// Поля-люди (несколько): логины → массив Id в колонку <Field>Id.
|
||||||
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
||||||
// Числовые поля: пустая строка → null, иначе Number().
|
// Числовые поля: пустая строка → null, иначе Number().
|
||||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation'];
|
const NUMBER_FIELDS: (keyof IFormData)[] = ['OpenVacanciesCount', 'RateProbation', 'RateAfterProbation'];
|
||||||
|
|
||||||
/** Экземпляр сервиса списка согласования этой формы. */
|
/** Экземпляр сервиса списка согласования этой формы. */
|
||||||
export function selectionApprovalService(): ApprovalListService {
|
export function selectionApprovalService(): ApprovalListService {
|
||||||
@@ -29,6 +29,26 @@ function buildScalarBody(data: IFormData): Record<string, unknown> {
|
|||||||
if (PERSON_FIELDS.indexOf(key) !== -1 || MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
if (PERSON_FIELDS.indexOf(key) !== -1 || MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'Companies') {
|
||||||
|
// Мультивыбор компаний не разворачивается в колонки — сериализуем в Note.
|
||||||
|
item.CompaniesJson = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'Languages') {
|
||||||
|
// Таблица «язык + уровень» — сериализуем в Note.
|
||||||
|
item.LanguagesJson = data.Languages.length > 0 ? JSON.stringify(data.Languages) : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'Programs') {
|
||||||
|
// Мультивыбор программ — сериализуем в Note.
|
||||||
|
item.ProgramsJson = data.Programs.length > 0 ? JSON.stringify(data.Programs) : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'WorkplaceProvision') {
|
||||||
|
// Мультивыбор категорий обеспечения — сериализуем в Note.
|
||||||
|
item.WorkplaceProvisionJson = data.WorkplaceProvision.length > 0 ? JSON.stringify(data.WorkplaceProvision) : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
item[key] = value;
|
item[key] = value;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||||
|
import { ICompany } from './models/ICompany';
|
||||||
|
|
||||||
|
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
|
||||||
|
// { Id, Name } (без обёртки, как /Dictionary/Departments).
|
||||||
|
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Dictionary/Companies`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Dictionary/Companies: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
||||||
|
// промис повторным вызовам (как в departments.service).
|
||||||
|
let cache: Promise<ICompany[]> | undefined;
|
||||||
|
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||||
|
if (!cache) {
|
||||||
|
cache = fetchCompanies(context).catch((e) => {
|
||||||
|
cache = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
@@ -6,3 +6,11 @@ export { getProjects } from './projects.service';
|
|||||||
export type { IProject } from './models/IProject';
|
export type { IProject } from './models/IProject';
|
||||||
export { ProjectPicker } from './ui/ProjectPicker';
|
export { ProjectPicker } from './ui/ProjectPicker';
|
||||||
export type { IProjectPickerProps } from './ui/ProjectPicker';
|
export type { IProjectPickerProps } from './ui/ProjectPicker';
|
||||||
|
export { getCompanies } from './companies.service';
|
||||||
|
export type { ICompany } from './models/ICompany';
|
||||||
|
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
|
||||||
|
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
|
||||||
|
export { getProjects1C } from './projects1c.service';
|
||||||
|
export type { IProject1C } from './models/IProject1C';
|
||||||
|
export { Project1CPicker } from './ui/Project1CPicker';
|
||||||
|
export type { IProject1CPickerProps } from './ui/Project1CPicker';
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name }
|
||||||
|
// (PascalCase, без обёртки — как /Dictionary/Departments).
|
||||||
|
export interface ICompany {
|
||||||
|
/** GUID компании — стабильный идентификатор. */
|
||||||
|
Id: string;
|
||||||
|
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
|
||||||
|
Name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// GET https://api.tnsu.kz/Dictionary/Projects1C → плоский массив
|
||||||
|
// { Id, Code, ProjectName } (PascalCase, без обёртки — как /Dictionary/Companies).
|
||||||
|
// Это отдельный справочник 1С-проектов; не путать с /Project (approval-route).
|
||||||
|
export interface IProject1C {
|
||||||
|
/** GUID проекта — стабильный идентификатор. */
|
||||||
|
Id: string;
|
||||||
|
/** Код проекта (напр. «БК0000069»). */
|
||||||
|
Code: string;
|
||||||
|
/** Наименование проекта — то, что видит пользователь. */
|
||||||
|
ProjectName: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||||
|
import { IProject1C } from './models/IProject1C';
|
||||||
|
|
||||||
|
// GET /Dictionary/Projects1C — справочник проектов 1С, ответ — плоский массив
|
||||||
|
// { Id, Code, ProjectName } (без обёртки, как /Dictionary/Companies).
|
||||||
|
async function fetchProjects1C(context: WebPartContext): Promise<IProject1C[]> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Dictionary/Projects1C`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Dictionary/Projects1C: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
||||||
|
// промис повторным вызовам (как в companies/departments.service).
|
||||||
|
let cache: Promise<IProject1C[]> | undefined;
|
||||||
|
export function getProjects1C(context: WebPartContext): Promise<IProject1C[]> {
|
||||||
|
if (!cache) {
|
||||||
|
cache = fetchProjects1C(context).catch((e) => {
|
||||||
|
cache = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $sp-1;
|
||||||
|
margin-bottom: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 5px 6px 5px 10px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $navy-50;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipRemove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-200;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './CompanyPickerMulti.module.scss';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { getCompanies } from '../companies.service';
|
||||||
|
import { ICompany } from '../models/ICompany';
|
||||||
|
|
||||||
|
export interface ICompanyPickerMultiProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
id?: string;
|
||||||
|
/** Выбранные компании (Id + Name). */
|
||||||
|
value: ICompany[];
|
||||||
|
onChange: (companies: ICompany[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мультивыбор компаний из справочника (GET /Dictionary/Companies): ввод текста
|
||||||
|
* фильтрует список, выбранные показываются чипами. Хранит массив { Id, Name }.
|
||||||
|
*/
|
||||||
|
export const CompanyPickerMulti: React.FC<ICompanyPickerMultiProps> = ({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
|
||||||
|
const [companies, setCompanies] = React.useState<ICompany[]>([]);
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getCompanies(context)
|
||||||
|
.then((list) => {
|
||||||
|
if (alive) setCompanies(list);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
const selectedIds = React.useMemo(() => new Set(value.map((c) => c.Id)), [value]);
|
||||||
|
|
||||||
|
const suggestions = React.useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return companies
|
||||||
|
.filter((c) => !selectedIds.has(c.Id) && c.Name.toLowerCase().indexOf(q) !== -1)
|
||||||
|
.slice(0, MAX_SUGGESTIONS);
|
||||||
|
}, [companies, query, selectedIds]);
|
||||||
|
|
||||||
|
const add = (company: ICompany): void => {
|
||||||
|
onChange([...value, { Id: company.Id, Name: company.Name }]);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (companyId: string): void => {
|
||||||
|
onChange(value.filter((c) => c.Id !== companyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className={styles.chips}>
|
||||||
|
{value.map((c) => (
|
||||||
|
<span key={c.Id} className={styles.chip}>
|
||||||
|
{c.Name}
|
||||||
|
{!effectiveReadOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.chipRemove}
|
||||||
|
aria-label={`Убрать ${c.Name}`}
|
||||||
|
title="Убрать"
|
||||||
|
onClick={() => remove(c.Id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!effectiveReadOnly && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
value={query}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{open && suggestions.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{suggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.Id}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => add(c)}
|
||||||
|
>
|
||||||
|
{c.Name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
// справа место под крестик-очистку
|
||||||
|
padding: 11px 36px 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':read-only');
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './Project1CPicker.module.scss';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { getProjects1C } from '../projects1c.service';
|
||||||
|
import { IProject1C } from '../models/IProject1C';
|
||||||
|
|
||||||
|
export interface IProject1CPickerProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
id?: string;
|
||||||
|
/** Текущее наименование проекта (то, что видно в поле). */
|
||||||
|
value: string;
|
||||||
|
/**
|
||||||
|
* name — введённый/выбранный текст; id — GUID проекта ('' при ручном вводе);
|
||||||
|
* code — код проекта ('' при ручном вводе). При выборе из списка приходят все три.
|
||||||
|
*/
|
||||||
|
onChange: (name: string, id: string, code: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 30;
|
||||||
|
|
||||||
|
/** Поле выбора проекта из справочника 1С (GET /Dictionary/Projects1C): ввод текста фильтрует список по названию/коду. */
|
||||||
|
export const Project1CPicker: React.FC<IProject1CPickerProps> = ({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
|
||||||
|
const [projects, setProjects] = React.useState<IProject1C[]>([]);
|
||||||
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getProjects1C(context)
|
||||||
|
.then((list) => {
|
||||||
|
if (alive) setProjects(list);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
const suggestions = React.useMemo(() => {
|
||||||
|
const query = value.trim().toLowerCase();
|
||||||
|
if (!query) return [];
|
||||||
|
return projects
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.ProjectName.toLowerCase().indexOf(query) !== -1 ||
|
||||||
|
(p.Code && p.Code.toLowerCase().indexOf(query) !== -1)
|
||||||
|
)
|
||||||
|
.slice(0, MAX_SUGGESTIONS);
|
||||||
|
}, [projects, value]);
|
||||||
|
|
||||||
|
const handlePick = (project: IProject1C): void => {
|
||||||
|
onChange(project.ProjectName, project.Id, project.Code);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value, '', '');
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{!effectiveReadOnly && value.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.clear}
|
||||||
|
aria-label="Очистить"
|
||||||
|
title="Очистить"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
onChange('', '', '');
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!effectiveReadOnly && open && suggestions.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{suggestions.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.Id}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handlePick(p)}
|
||||||
|
>
|
||||||
|
{p.ProjectName}
|
||||||
|
<span className={styles.meta}>{p.Code}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user