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

This commit is contained in:
Тимур Городецкий
2026-09-16 17:59:45 +05:00
parent 428122d32a
commit 6f8bea33a2
31 changed files with 595 additions and 101 deletions
@@ -194,8 +194,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
const isValid = data.HireCandidateName.trim().length > 0 && data.Position.trim().length > 0;
// При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная.
const mainCompanyMissing = data.Companies.length >= 2 && !data.MainCompanyId;
const isValid = data.HireCandidateName.trim().length > 0 && data.Position.trim().length > 0 && !mainCompanyMissing;
const validate = (): string | undefined => {
if (mainCompanyMissing) return 'Выбрано несколько организаций — укажите, какая из них основное место работы.';
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -1,7 +1,7 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ICompany } from '../../shared/dictionary';
import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare } from './model';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
function parseJsonArray<T>(raw: unknown): T[] {
@@ -16,6 +16,7 @@ function parseJsonArray<T>(raw: unknown): T[] {
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
const SCALAR_FIELDS: (keyof IFormData)[] = [
'MainCompanyId',
'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
'HireCandidateName', 'HireHireDate', 'Position', 'WorkKind',
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
@@ -44,13 +45,15 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId)
.select(...SCALAR_FIELDS, 'CompaniesJson', 'RecruitingAgency', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
.select(...SCALAR_FIELDS, 'CompaniesJson', 'CompanySharesJson', 'RecruitingAgency', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>;
// Мультивыбор компаний хранится Note-колонкой CompaniesJson (не одноимённой колонкой).
(data as unknown as Record<string, unknown>).Companies = parseJsonArray<ICompany>(item.CompaniesJson);
// % ставки по компаниям (совмещение) — аналогично, Note-колонка CompanySharesJson.
(data as unknown as Record<string, unknown>).CompanyShares = parseJsonArray<ICompanyShare>(item.CompanySharesJson);
// Флаг агентства — булево поле (Yes/No), не строка.
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
@@ -13,9 +13,19 @@ export const FORM_ID: RequestTypeId = 'recruiting-hire';
* Данные формы «Заявка на прием». Ключи = internalName колонок списка данных
* этой формы (см. shared/listTitles.ts), чтобы при записи маппинг был 1:1.
*/
/** % ставки на одну из выбранных компаний (см. CompanySharesField) — актуально при совмещении (2+ компании). */
export interface ICompanyShare {
companyId: string;
percent: string;
}
export interface IFormData {
// Заявка (шапка)
Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson
/** Id компании из Companies — «Основное место работы»; '' — не выбрано (или выбрана всего 1 компания). */
MainCompanyId: string;
/** % ставки на каждую компанию из Companies — заполняется при совмещении (2+ компании), хранится CompanySharesJson. */
CompanyShares: ICompanyShare[];
Phone: string;
HireType: string;
/**
@@ -74,6 +84,8 @@ export interface ISectionProps {
export const EMPTY_DATA: IFormData = {
Companies: [],
MainCompanyId: '',
CompanyShares: [],
Phone: '',
HireType: '',
HireReplacedEmployee: '',
@@ -5,6 +5,8 @@
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
{ "internalName": "MainCompanyId", "title": "Основное место работы (Id компании)", "type": "Text" },
{ "internalName": "CompanySharesJson", "title": "Распределение ЗП по компаниям (JSON)", "type": "Note" },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
@@ -17,8 +17,8 @@ export const AgencySection: React.FC<ISectionProps> = ({ data, set }) => {
};
return (
<CollapseSection title="Рекрутинговое агентство" icon="building">
<FormField label="Привлекается рекрутинговое агентство">
<CollapseSection title="5. Рекрутинговое агентство" icon="building">
<FormField label="5.1 Привлекается рекрутинговое агентство">
<Toggle checked={data.RecruitingAgency} onChange={handleToggle} onLabel="Да" />
</FormField>
{data.RecruitingAgency && (
@@ -1,8 +1,9 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
import { CompanyPickerMulti, ProjectPicker } from '../../../shared/dictionary';
import { CompanyPickerMulti, ProjectPicker, ICompany } from '../../../shared/dictionary';
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT } from '../model';
import { SelectionRequestField } from './SelectionRequestField';
import { CompanySharesField } from './CompanySharesField';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => {
const isReplacement = data.HireType === HIRE_TYPE_REPLACEMENT;
@@ -14,18 +15,41 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
}
};
// Компания убрана из выбора — чистим ссылки на неё в «Основное место
// работы» / % ставки, иначе они молча уйдут в заявку по несуществующей компании.
const handleCompaniesChange = (companies: ICompany[]): void => {
set('Companies', companies);
const ids = new Set(companies.map((c) => c.Id));
if (companies.length < 2) {
set('MainCompanyId', '');
set('CompanyShares', []);
return;
}
if (data.MainCompanyId && !ids.has(data.MainCompanyId)) {
set('MainCompanyId', '');
}
set('CompanyShares', data.CompanyShares.filter((s) => ids.has(s.companyId)));
};
return (
<CollapseSection title="Заявка" icon="clipboard">
<FormField label="Организация" htmlFor="Companies" full>
<CollapseSection title="1. Заявка" icon="clipboard">
<FormField label="1.1 Организация" htmlFor="Companies" full>
<CompanyPickerMulti
id="Companies"
context={context}
value={data.Companies}
onChange={(companies) => set('Companies', companies)}
onChange={handleCompaniesChange}
placeholder="Начните вводить название организации…"
/>
</FormField>
<FormField label="Проект" htmlFor="Project" full>
<CompanySharesField
companies={data.Companies}
mainCompanyId={data.MainCompanyId}
onMainCompanyChange={(v) => set('MainCompanyId', v)}
shares={data.CompanyShares}
onSharesChange={(v) => set('CompanyShares', v)}
/>
<FormField label="1.2 Проект" htmlFor="Project" full>
<ProjectPicker
context={context}
id="Project"
@@ -38,13 +62,13 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
placeholder="Начните вводить название или код проекта…"
/>
</FormField>
<FormField label="Заявка на подбор" full>
<FormField label="1.3 Заявка на подбор" full>
<SelectionRequestField data={data} set={set} />
</FormField>
<FormField label="Телефон" htmlFor="Phone">
<FormField label="1.4 Телефон" htmlFor="Phone">
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
</FormField>
<FormField label="Тип приёма" htmlFor="HireType">
<FormField label="1.5 Тип приёма" htmlFor="HireType">
<Select
id="HireType"
value={data.HireType}
@@ -3,23 +3,23 @@ import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { ISectionProps } from '../model';
export const CandidateSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Кандидат" icon="people">
<FormField label="Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
<CollapseSection title="2. Кандидат" icon="people">
<FormField label="2.1 Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
<TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
</FormField>
<FormField label="Дата приёма" htmlFor="HireHireDate">
<FormField label="2.2 Дата приёма" htmlFor="HireHireDate">
<TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<FormField label="2.3 Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Вид работ" htmlFor="WorkKind">
<FormField label="2.4 Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField>
<FormField label="Отдел/Участок" htmlFor="Department">
<FormField label="2.5 Отдел/Участок" htmlFor="Department">
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
</FormField>
<FormField label="Местоположение" htmlFor="SiteLocation">
<FormField label="2.6 Местоположение" htmlFor="SiteLocation">
<TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} />
</FormField>
</CollapseSection>
@@ -0,0 +1,43 @@
@import '../../../../../styles/design-tokens';
.row {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
}
.name {
word-break: break-word;
}
// 46px — под 3 цифры (до 100) с учётом padding borderless-варианта поля
// (6px 8px, см. @tnsu/ui-kit). Нативный спиннер number съедал бы почти всё
// место в таком узком поле — убираем его, стрелки тут не нужны.
.item :global(input) {
width: 46px;
text-align: right;
-moz-appearance: textfield;
}
.item :global(input)::-webkit-outer-spin-button,
.item :global(input)::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.percentSign {
color: $gray-500;
}
@@ -0,0 +1,70 @@
import * as React from 'react';
import styles from './CompanySharesField.module.scss';
import { FormField, Select, TextInput, ISelectOption } from '../../../shared/ui';
import { ICompany } from '../../../shared/dictionary';
import { ICompanyShare } from '../model';
export interface ICompanySharesFieldProps {
companies: ICompany[];
mainCompanyId: string;
onMainCompanyChange: (companyId: string) => void;
shares: ICompanyShare[];
onSharesChange: (shares: ICompanyShare[]) => void;
}
/**
* Совмещение (2+ выбранные компании в «Организация»): выбор основного места
* работы + % ставки на каждую компанию. При 1 компании (или 0) не рендерится —
* совмещать нечего, она и так единственное место работы.
*/
export const CompanySharesField: React.FC<ICompanySharesFieldProps> = ({
companies,
mainCompanyId,
onMainCompanyChange,
shares,
onSharesChange
}) => {
if (companies.length < 2) return null;
const mainOptions: ISelectOption[] = companies.map((c) => ({ value: c.Id, label: c.Name }));
const percentOf = (companyId: string): string => shares.find((s) => s.companyId === companyId)?.percent || '';
const setPercent = (companyId: string, percent: string): void => {
const rest = shares.filter((s) => s.companyId !== companyId);
onSharesChange(percent === '' ? rest : [...rest, { companyId, percent }]);
};
return (
<>
<FormField label="Основное место работы" htmlFor="MainCompanyId" required>
<Select
id="MainCompanyId"
value={mainCompanyId}
onChange={onMainCompanyChange}
options={mainOptions}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Распределение ЗП по компаниям" full>
<div className={styles.row}>
{companies.map((c) => (
<div key={c.Id} className={styles.item}>
<span className={styles.name}>{c.Name}</span>
<TextInput
type="number"
min={0}
max={100}
value={percentOf(c.Id)}
onChange={(v) => setPercent(c.Id, v)}
ariaLabel={`% ставки — ${c.Name}`}
borderless
/>
<span className={styles.percentSign}>%</span>
</div>
))}
</div>
</FormField>
</>
);
};
@@ -3,8 +3,8 @@ import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../..
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Условия оплаты и оформления" icon="calculator">
<FormField label="Испытательный срок" htmlFor="ProbationPeriod">
<CollapseSection title="4. Условия оплаты и оформления" icon="calculator">
<FormField label="4.1 Испытательный срок" htmlFor="ProbationPeriod">
<Select
id="ProbationPeriod"
value={data.ProbationPeriod}
@@ -13,21 +13,21 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
placeholder="— выберите —"
/>
</FormField>
<FormField label="Оклад при испытательном сроке" htmlFor="SalaryProbation">
<FormField label="4.2 Оклад при испытательном сроке" htmlFor="SalaryProbation">
<MoneyInput
id="SalaryProbation"
value={data.SalaryProbation}
onChange={(v) => set('SalaryProbation', v)}
/>
</FormField>
<FormField label="Оклад по завершении ИС" htmlFor="SalaryAfterProbation">
<FormField label="4.3 Оклад по завершении ИС" htmlFor="SalaryAfterProbation">
<MoneyInput
id="SalaryAfterProbation"
value={data.SalaryAfterProbation}
onChange={(v) => set('SalaryAfterProbation', v)}
/>
</FormField>
<FormField label="График работы" htmlFor="WorkSchedule">
<FormField label="4.4 График работы" htmlFor="WorkSchedule">
<Select
id="WorkSchedule"
value={data.WorkSchedule}
@@ -3,14 +3,14 @@ import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { ISectionProps } from '../model';
export const PpeSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="СИЗ (спецодежда)" icon="helmet">
<FormField label="Рост, см" htmlFor="PpeHeight">
<CollapseSection title="3. СИЗ (спецодежда)" icon="helmet">
<FormField label="3.1 Рост, см" htmlFor="PpeHeight">
<TextInput id="PpeHeight" type="number" value={data.PpeHeight} onChange={(v) => set('PpeHeight', v)} />
</FormField>
<FormField label="Размер одежды" htmlFor="PpeClothingSize">
<FormField label="3.2 Размер одежды" htmlFor="PpeClothingSize">
<TextInput id="PpeClothingSize" value={data.PpeClothingSize} onChange={(v) => set('PpeClothingSize', v)} />
</FormField>
<FormField label="Размер обуви" htmlFor="PpeShoeSize">
<FormField label="3.3 Размер обуви" htmlFor="PpeShoeSize">
<TextInput id="PpeShoeSize" type="number" value={data.PpeShoeSize} onChange={(v) => set('PpeShoeSize', v)} />
</FormField>
</CollapseSection>
@@ -29,6 +29,11 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
item.CompaniesJson = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
return;
}
if (key === 'CompanyShares') {
// % ставки по компаниям (совмещение) — таблица, сериализуем в Note.
item.CompanySharesJson = data.CompanyShares.length > 0 ? JSON.stringify(data.CompanyShares) : null;
return;
}
const value = data[key];
if (NUMBER_FIELDS.indexOf(key) !== -1) {
item[key] = value === '' ? null : Number(value);
@@ -44,7 +44,7 @@ import {
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model';
import { saveSelectionRequest, selectionApprovalService } from './submit';
import { loadSelectionRequest } from './load';
import { ApplicationSection } from './sections/ApplicationSection';
@@ -125,7 +125,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
}
let alive = true;
const user = context.pageContext.user;
setData((prev) => ({ ...prev, Responsible: prev.Responsible || user.email }));
setData((prev) => ({ ...prev, Responsible: prev.Responsible || user.email, SubmitDate: todayIso() }));
getEmployeeOrganizations(context, user.email)
.then((records) => {
if (alive) {
@@ -365,7 +365,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
editor={matrix}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Выберите «Объект/Проект» (п. 1.6), чтобы построить маршрут согласования."
waitingText="Выберите «Объект/Проект» (п. 1.7), чтобы построить маршрут согласования."
/>
<SubmitActions
@@ -16,6 +16,7 @@ export const FORM_ID: RequestTypeId = 'recruiting-selection';
export interface IFormData {
// Заявка
Responsible: string;
/** ISO YYYY-MM-DD, всегда сегодняшняя дата — поле read-only (см. todayIso). */
SubmitDate: string;
ClosingDeadline: string;
/**
@@ -65,7 +66,6 @@ export interface IFormData {
RateAfterProbation: string;
Awards: string;
Bonuses: string;
CompensationPackage: string;
PieceworkPay: boolean;
WorkSchedule: string;
WorkScheduleOther: string;
@@ -128,7 +128,6 @@ export const EMPTY_DATA: IFormData = {
RateAfterProbation: '',
Awards: '',
Bonuses: '',
CompensationPackage: '',
PieceworkPay: false,
WorkSchedule: '',
WorkScheduleOther: '',
@@ -144,8 +143,10 @@ export const EMPTY_DATA: IFormData = {
/** Варианты «Характер занятости» — совпадают с choices колонки EmploymentType. */
export const EMPLOYMENT_TYPE_OPTIONS: ISelectOption[] = [
{ value: 'Постоянная', label: 'Постоянная' },
{ value: 'Временная', label: 'Временная' }
{ value: 'Постоянный', label: 'Постоянный' },
{ value: 'Временный', label: 'Временный' },
{ value: 'Гибридный', label: 'Гибридный' },
{ value: 'Удалённый', label: 'Удалённый' }
];
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */
@@ -252,25 +253,33 @@ export const WORK_EXPERIENCE_OPTIONS: ISelectOption[] = [
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: 'Вахтовый 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. */
/** Варианты «Время работы» (24-часовой формат) — совпадают с 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: '08:00-17:00', label: '08:00-17:00' },
{ value: '09:00-18:00', label: '09:00-18:00' },
{ value: '07:00-19:00', label: '07:00-19:00' },
{ value: '08:00-20:00', label: '08:00-20:00' },
{ value: '09:00-21:00', label: '09:00-21:00' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
];
/** Плейсхолдер для полей-людей. */
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
/** Сегодняшняя дата как YYYY-MM-DD — значение поля «Дата подачи заявки» (read-only). */
export function todayIso(): string {
const now = new Date();
const m = now.getMonth() + 1;
const d = now.getDate();
return `${now.getFullYear()}-${m < 10 ? '0' : ''}${m}-${d < 10 ? '0' : ''}${d}`;
}
@@ -19,7 +19,7 @@
{ "internalName": "OpenVacanciesCount", "title": "Количество открытых вакансий", "type": "Number" },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
{ "internalName": "EmploymentType", "title": "Характер занятости", "type": "Choice", "choices": ["Постоянная", "Временная"] },
{ "internalName": "EmploymentType", "title": "Характер занятости", "type": "Choice", "choices": ["Постоянный", "Временный", "Гибридный", "Удалённый"] },
{ "internalName": "JobFunction", "title": "Функционал", "type": "Note" },
{ "internalName": "Competencies", "title": "Компетенции", "type": "Note" },
{ "internalName": "PersonalTraits", "title": "Личностные характеристики", "type": "Note" },
@@ -39,17 +39,16 @@
{ "internalName": "RateAfterProbation", "title": "Ставка после ИС", "type": "Number", "decimals": 2 },
{ "internalName": "Awards", "title": "Премии", "type": "Note" },
{ "internalName": "Bonuses", "title": "Бонусы", "type": "Note" },
{ "internalName": "CompensationPackage", "title": "Компенсационный пакет", "type": "Note" },
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
{ "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": "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": "WorkHours", "title": "Время работы", "type": "Choice", "choices": ["08:00-17:00", "09:00-18:00", "07:00-19:00", "08:00-20:00", "09:00-21:00", "Другое"] },
{ "internalName": "WorkHoursOther", "title": "Время работы (другое)", "type": "Text" },
{ "internalName": "MaterialResponsibility", "title": "Материальная ответственность", "type": "Boolean" },
{ "internalName": "MaterialResponsibility", "title": "Материальная ответственность (строительные площадки, склады)", "type": "Boolean" },
{ "internalName": "WorkplaceProvisionJson", "title": "Обеспечение рабочего места (JSON)", "type": "Note" },
{ "internalName": "ProposedMentor", "title": "Предполагаемый наставник", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "DirectManager", "title": "Непосредственный руководитель", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ReferralProgram", "title": "Реферальная программа", "type": "Boolean" },
{ "internalName": "ReferralProgram", "title": "Охватывается реферальной программой", "type": "Boolean" },
{ "internalName": "HiringDecisionMaker", "title": "Кто принимает решение о приеме (собеседования)", "type": "User", "userSelectionMode": "PeopleOnly", "allowMultiple": true }
]
}
@@ -35,7 +35,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
/>
</FormField>
<FormField label="1.3 Дата подачи заявки" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
<FormField label="1.4 Крайний срок закрытия" htmlFor="ClosingDeadline">
<TextInput
@@ -54,25 +54,14 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
onChange={(v) => set("Bonuses", v)}
/>
</FormField>
<FormField
label="5.5 Компенсационный пакет"
htmlFor="CompensationPackage"
full
>
<TextArea
id="CompensationPackage"
value={data.CompensationPackage}
onChange={(v) => set("CompensationPackage", v)}
/>
</FormField>
<FormField label="5.6 Сдельная оплата труда">
<FormField label="5.5 Сдельная оплата труда">
<Toggle
checked={data.PieceworkPay}
onChange={(v) => set("PieceworkPay", v)}
onLabel="Да"
/>
</FormField>
<FormField label="5.7 График работы" htmlFor="WorkSchedule">
<FormField label="5.6 График работы" htmlFor="WorkSchedule">
<Select
id="WorkSchedule"
value={data.WorkSchedule}
@@ -93,7 +82,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
/>
</FormField>
)}
<FormField label="5.8 Время работы" htmlFor="WorkHours">
<FormField label="5.7 Время работы" htmlFor="WorkHours">
<Select
id="WorkHours"
value={data.WorkHours}
@@ -114,14 +103,14 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
/>
</FormField>
)}
<FormField label="5.9 Материальная ответственность">
<FormField label="5.8 Материальная ответственность (строительные площадки, склады)">
<Toggle
checked={data.MaterialResponsibility}
onChange={(v) => set("MaterialResponsibility", v)}
onLabel="Да"
/>
</FormField>
<FormField label="5.10 Обеспечение рабочего места" full>
<FormField label="5.9 Обеспечение рабочего места" full>
<MultiChoiceField
value={data.WorkplaceProvision}
onChange={(v) => set("WorkplaceProvision", v)}
@@ -130,14 +119,14 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
placeholder="Выберите категорию"
/>
</FormField>
<FormField label="5.11 Реферальная программа">
<FormField label="5.10 Охватывается реферальной программой">
<Toggle
checked={data.ReferralProgram}
onChange={(v) => set("ReferralProgram", v)}
onLabel="Да"
/>
</FormField>
<FormField label="5.12 Предполагаемый наставник">
<FormField label="5.11 Предполагаемый наставник">
<PeopleField
context={context}
value={data.ProposedMentor}
@@ -145,7 +134,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
<FormField label="5.13 Непосредственный руководитель">
<FormField label="5.12 Непосредственный руководитель">
<PeopleField
context={context}
value={data.DirectManager}
@@ -153,7 +142,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
<FormField label="5.14 Кто принимает решение о приеме (собеседования)" full>
<FormField label="5.13 Кто принимает решение о приеме (собеседования)" full>
<PeopleFieldMulti
context={context}
value={data.HiringDecisionMaker}
@@ -6,6 +6,10 @@ import {
FormCard,
InitiatorLine,
FormModeProvider,
FileDropzone,
AttachmentList,
CollapseSection,
FormField,
Icon,
RequestNumber,
SubmitActions,
@@ -26,6 +30,12 @@ import {
APPROVAL_PHASE,
initialSubmitStatus
} from '../../shared/approval';
import {
uploadAttachments,
getAttachments,
deleteAttachment,
IAttachmentInfo
} from '../../shared/attachments/attachments.service';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -55,6 +65,8 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
@@ -86,13 +98,14 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
return;
}
let alive = true;
loadReferralRequest(editId, editListId)
.then((loaded) => {
Promise.all([loadReferralRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code);
setExistingAttachments(atts);
}
})
.catch((e) => {
@@ -272,6 +285,15 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
autoApprove: r.autoApprove
}));
const handleRemoveAttachment = (fileName: string): void => {
if (requestId === undefined) {
return;
}
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined);
};
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова
// (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу
@@ -298,6 +320,11 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
if (code) {
setRequestCode(code);
}
if (files.length > 0) {
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
}
if (onSaved) {
onSaved();
}
@@ -397,6 +424,19 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
<AssessmentSection {...sectionProps} />
</FormModeProvider>
{!readOnly && (
<CollapseSection title="Вложения" icon="upload">
{existingAttachments.length > 0 && (
<FormField label="Прикреплённые файлы" full>
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
</FormField>
)}
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection>
)}
{/* Блок остаётся видимым и пустым (не скрываем по noApprovalPhase) —
маршрут без согласующих не значит, что их нельзя добавить: матрица
редактируема, заявитель вправе вписать согласующего сам. */}
@@ -2,7 +2,17 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ReferralView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
CollapseSection,
FormField,
AttachmentList,
PrintSplitButton,
OrgLetterhead
} from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
@@ -14,6 +24,7 @@ import {
APPROVAL_PHASE,
isApprovedStatus
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -57,6 +68,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций рекомендателя (см. OrganizationField) идёт в печать —
@@ -101,13 +113,14 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
useEffect(() => {
let alive = true;
setLoading(true);
loadReferralRequest(itemId, listId)
.then((loaded) => {
Promise.all([loadReferralRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
.then(([loaded, files]) => {
if (alive) {
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
notifyCode(loaded.code);
setAttachments(files);
setError(null);
setLoading(false);
}
@@ -176,6 +189,14 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
<CandidateInfoSection {...sectionProps} />
<AssessmentSection {...sectionProps} />
</FormModeProvider>
<div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<AttachmentList items={attachments} />
</FormField>
</CollapseSection>
</div>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
@@ -25,6 +25,15 @@
`editable=false` даже в форме подачи, не только в просмотре. Если шаг маршрута не
разрешился в конкретного человека — отправка блокируется с просьбой обратиться к
администратору маршрутов (сам заявитель это не чинит).
- **Шаг «Руководитель» переопределяется полем формы.** Роль с `positionCode: "Chief"`
в ответе `resolve` approval-route резолвит сам по своей оргструктуре — это не всегда
тот, кого заявитель вписал/выбрал в поле «Руководитель» (ContactSection,
`PeopleTextField`). `managerOverride.ts` после загрузки планируемой матрицы находит
строку с этим `positionCode` и подставляет туда email/ФИО из `data.Manager` вместо
того, что вернул resolve. Пустое/нераспознанное значение поля → шаг остаётся без
исполнителя, это ловит тот же `unassignedStepMessage`, что и обычный незаполненный
шаг. Работает только для НОВОЙ заявки (`positionCode` есть только у планируемого
маршрута, не у уже записанных строк списка согласования).
## Особое поведение
- **Контакты заявителя предзаполняются через Microsoft Graph** (`/me`): ФИО, email,
@@ -51,6 +51,7 @@ import { getApplicantProfile } from './services/profile.service';
import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback';
import { applyManagerOverride, managerEmail } from './managerOverride';
const Ticket1CForm: React.FC<IRequestFormProps> = ({
requestType,
@@ -182,6 +183,43 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
!!editId || (!!data.WorkType && !!requestTypeId),
(login) => approval.getJobTitle(login)
);
// Должность нового «Руководителя» — approval-route её не знает (резолвил шаг
// на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по
// e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих,
// см. useMatrixEditor.getJobTitle выше). undefined, пока запрос не отработал —
// applyManagerOverride в этом случае не трогает должность строки, чтобы не
// мигать пустым значением на каждое изменение поля.
const [managerPosition, setManagerPosition] = useState<string | undefined>(undefined);
useEffect(() => {
const email = managerEmail(data.Manager);
if (!email) {
setManagerPosition(undefined);
return;
}
let alive = true;
setManagerPosition(''); // сбрасываем сразу — иначе на экране на миг видна должность ПРЕЖНЕГО руководителя
approval
.getJobTitle(email)
.then((title) => {
if (alive) setManagerPosition(title);
})
.catch(() => {
if (alive) setManagerPosition('');
});
return () => {
alive = false;
};
}, [data.Manager, approval]);
// Шаг с ролью «Руководитель» (positionCode Chief) переопределяем полем формы,
// а не тем, кого approval-route резолвит сам по оргструктуре — см.
// managerOverride.ts. Пересчёт локальный (без похода в resolve), поэтому
// безопасно зависеть от data.Manager, который меняется на каждое нажатие клавиши.
const matrixRows = useMemo(
() => applyManagerOverride(matrix.rows, data.Manager, managerPosition),
[matrix.rows, data.Manager, managerPosition]
);
const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]);
const isValid = !!(
data.TaskName.trim() &&
@@ -248,7 +286,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
const { id, code } = await saveTicketRequest(
requestId,
data,
toApprovers(matrix.rows),
toApprovers(matrixRows),
finalStatus,
writeListId
);
@@ -297,7 +335,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
// незаполненный шаг заявитель исправить сам не может, только администратор
// маршрутов (роль в шаге не разрешилась в конкретного человека).
const unassignedError = unassignedStepMessage(
matrix.rows,
matrixRows,
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
);
if (unassignedError) return unassignedError;
@@ -360,7 +398,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
)}
<ApprovalMatrix
editor={matrix}
editor={matrixEditor}
search={(q) => approval.searchUsers(q)}
emptyText="Маршрут для этого вида работ пока не настроен — обратитесь к администратору маршрутов согласования."
/>
@@ -0,0 +1,55 @@
import { IMatrixRow } from '../../shared/approval';
// Формат значения поля «Руководитель» — тот же, что у автоподстановки
// (Ticket1CForm.tsx) и у PeopleTextField/ContactSection: «ФИО · email».
const MANAGER_SEPARATOR = ' · ';
const CHIEF_POSITION_CODE = 'Chief';
const EMAIL_RE = /^\S+@\S+\.\S+$/;
/** Email из значения поля «Руководитель» — используется для запроса должности (см. Ticket1CForm.tsx). */
export function managerEmail(value: string): string {
return parseManager(value).email;
}
function parseManager(value: string): { name: string; email: string } {
const trimmed = value.trim();
const sepIndex = trimmed.lastIndexOf(MANAGER_SEPARATOR);
if (sepIndex !== -1) {
return {
name: trimmed.slice(0, sepIndex).trim(),
email: trimmed.slice(sepIndex + MANAGER_SEPARATOR.length).trim()
};
}
// Ввели/вставили просто e-mail без ФИО (напр. руками, без пикера).
return { name: '', email: EMAIL_RE.test(trimmed) ? trimmed : '' };
}
/**
* Шаг маршрута с ролью «Руководитель» (positionCode Chief в админке маршрутов)
* approval-route резолвит сам по оргструктуре — этот источник не всегда
* совпадает с тем, что заявитель вписал в поле «Руководитель» (ContactSection),
* а заявитель мог осознанно указать другого человека через PeopleTextField или
* вручную. Поле формы должно быть главным источником для ЭТОГО шага.
*
* Пустой/нераспознанный e-mail в поле → шаг остаётся без назначенного
* исполнителя (email ''), это поймает unassignedStepMessage при отправке —
* отдельной проверки здесь не нужно.
*
* position — должность НОВОГО исполнителя (SPS-JobTitle по managerEmail, см.
* Ticket1CForm.tsx), которую approval-route вернуть не может — он резолвил
* шаг на другого человека и отдал ЕГО должность. Без этого параметра в
* колонке «Должность» осталась бы должность прежнего (авторезолвленного)
* исполнителя рядом с ФИО нового. undefined (запрос ещё не отработал/упал) →
* должность строки временно не меняем, чтобы не мигать пустым значением на
* каждое изменение поля.
*
* Только для ПЛАНИРУЕМОГО маршрута (positionCode есть только у него, см.
* IMatrixRow.positionCode) — для уже записанных строк заявки (editId) это
* no-op, что и нужно: решение согласующего задним числом не переписываем.
*/
export function applyManagerOverride(rows: IMatrixRow[], manager: string, position?: string): IMatrixRow[] {
const { name, email } = parseManager(manager);
return rows.map((r) =>
r.positionCode === CHIEF_POSITION_CODE ? { ...r, email, who: name || email, position: position ?? r.position } : r
);
}
@@ -22,7 +22,11 @@ export interface IFormData {
Phone: string;
Department: string;
Position: string;
/** Непосредственный руководитель заявителя — предзаполняется через getManagerEmail, редактируем. */
/**
* Непосредственный руководитель заявителя — предзаполняется через
* getManagerEmail, редактируем: вручную текстом или через PeopleTextField
* (кнопка «выбрать из списка сотрудников»), см. ContactSection.
*/
Manager: string;
Question: string;
}
@@ -1,8 +1,8 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, PhoneInput, PeopleTextField } from '../../../shared/ui';
import { ISectionProps } from '../model';
export const ContactSection: React.FC<ISectionProps> = ({ data, set }) => (
export const ContactSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Контакты заявителя" icon="people">
<FormField label="Ф.И.О." htmlFor="FIO">
<TextInput id="FIO" value={data.FIO} onChange={(v) => set('FIO', v)} />
@@ -20,7 +20,13 @@ export const ContactSection: React.FC<ISectionProps> = ({ data, set }) => (
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Руководитель" htmlFor="Manager">
<TextInput id="Manager" value={data.Manager} onChange={(v) => set('Manager', v)} />
<PeopleTextField
id="Manager"
context={context}
value={data.Manager}
onChange={(v) => set('Manager', v)}
placeholder="ФИО · email или выберите из списка"
/>
</FormField>
</CollapseSection>
);
@@ -40,6 +40,7 @@ export async function getPlannedMatrix(
askOverdueReason: s.askOverdueReason,
autoApprove: s.autoApprove,
warning: s.warning,
positionCode: s.positionCode,
}))
);
}
@@ -117,4 +117,12 @@ export interface IResolvedRouteStep {
* нечего, см. соответствующее поле на бэкенде (TemplateRouteStep.AutoApprove).
*/
autoApprove: boolean;
/**
* Код типовой роли шага (напр. "Chief" — непосредственный руководитель),
* настраивается в админке маршрутов вместе с самим шагом. undefined — роль
* без кода (обычный именной/групповой шаг). Используется формами точечно,
* когда нужно сопоставить конкретный шаг маршрута с полем формы (см.
* Ticket1C/managerOverride.ts — единственный пример на сейчас).
*/
positionCode?: string;
}
@@ -38,6 +38,12 @@ export interface IMatrixRow {
* IResolvedRouteStep.autoApprove).
*/
autoApprove?: boolean;
/**
* Код типовой роли шага (напр. "Chief"), см. IResolvedRouteStep.positionCode —
* то же транзитное поле: только у ПЛАНИРУЕМОГО маршрута (getPlannedMatrix), у
* уже записанных строк списка (loadXxxRequest) его нет.
*/
positionCode?: string;
/**
* Предупреждение approval-route по ЭТОМУ шагу (см. IResolvedRouteStep.warning) —
* например, роль не разрешилась в конкретного человека, и почему именно.
@@ -77,14 +77,6 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
else editor.moveBlockDown(rowIndex);
};
// Остальные правки состава — не перестановка: сбрасываем «активный» шаг,
// иначе подъём и подсветка достались бы случайной строке, которая сдвинулась
// из-за перенумерации после удаления.
const removeRow = (rowIndex: number): void => {
setActiveKeys([]);
editor.remove(rowIndex);
};
const toggleParallel = (rowIndex: number): void => {
setActiveKeys([]);
editor.toggleParallel(rowIndex);
@@ -239,14 +231,6 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
>
<Icon name="user-check" size="sm" />
</button>
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
onClick={() => removeRow(i)}
title="Удалить"
>
<Icon name="trash" size="sm" />
</button>
</td>
)}
</tr>
@@ -0,0 +1,52 @@
@import '../../../../../styles/design-tokens';
.wrap {
display: flex;
align-items: center;
gap: $sp-1;
}
.pickerBox {
flex: 1;
min-width: 0;
}
.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;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
.modeButton {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $gray-500;
cursor: pointer;
&:hover {
background: $navy-50;
color: $gray-900;
}
}
@@ -0,0 +1,110 @@
import * as React from 'react';
import { useState } from 'react';
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { Icon, useFormReadOnly } from '@tnsu/ui-kit';
import { buildPickerContext } from './pickerContext';
import { peoplePickerStyles } from './peoplePickerStyles';
import styles from './PeopleTextField.module.scss';
interface IPickedPerson {
text?: string;
secondaryText?: string;
loginName?: string;
}
interface IPeopleTextFieldProps {
context: WebPartContext;
id?: string;
/** Текущее значение (свободный текст — ФИО, «ФИО · email» и т.п.). */
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const SEPARATOR = ' · ';
const EMAIL_RE = /^\S+@\S+\.\S+$/;
/** Формат значения после выбора из PeoplePicker: «ФИО · email» — тот же, что у ручного ввода в этой форме. */
function formatPicked(person: IPickedPerson): string {
const email = person.secondaryText || person.loginName || '';
return person.text ? (email ? `${person.text}${SEPARATOR}${email}` : person.text) : email;
}
/** Email из значения — для defaultSelectedUsers пикера. Если распознать нельзя, пикер стартует пустым. */
function extractEmail(value: string): string | undefined {
const idx = value.lastIndexOf(SEPARATOR);
const email = idx !== -1 ? value.slice(idx + SEPARATOR.length).trim() : value.trim();
return EMAIL_RE.test(email) ? email : undefined;
}
/**
* Поле «сотрудник»: по умолчанию — PeoplePicker (с предзаполнением из value),
* кнопка переключает на обычный текстовый ввод для случаев, когда нужного
* человека нет в AAD или пикер не резолвит совпадение. В обоих режимах
* итоговое значение — свободная строка «ФИО · email», а не жёстко резолвленный
* пользователь (в отличие от PeopleField) — переключение между режимами саму
* строку не трогает.
*/
export const PeopleTextField: React.FC<IPeopleTextFieldProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [manual, setManual] = useState<boolean>(false);
const handlePick = (items: IPickedPerson[]): void => {
const first = items && items.length > 0 ? items[0] : undefined;
onChange(first ? formatPicked(first) : '');
};
const defaultEmail = extractEmail(value);
const defaultSelectedUsers = defaultEmail ? [defaultEmail] : [];
return (
<div className={styles.wrap}>
{manual ? (
<input
id={id}
className={styles.input}
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
onChange={(e) => onChange(e.target.value)}
/>
) : (
<div className={styles.pickerBox}>
<PeoplePicker
context={buildPickerContext(context)}
personSelectionLimit={1}
principalTypes={[PrincipalType.User]}
defaultSelectedUsers={defaultSelectedUsers}
resolveDelay={300}
ensureUser
disabled={effectiveReadOnly}
placeholder={placeholder}
onChange={handlePick}
styles={peoplePickerStyles}
/>
</div>
)}
{!effectiveReadOnly && (
<button
type="button"
className={styles.modeButton}
title={manual ? 'Выбрать из списка сотрудников' : 'Ввести вручную'}
onClick={() => setManual((prev) => !prev)}
>
<Icon name={manual ? 'search' : 'edit'} size="sm" />
</button>
)}
</div>
);
};
@@ -38,6 +38,7 @@ export { EMPTY_LINE_ITEM } from './ItemsTable/ILineItem';
export { parseExcelRows } from './ItemsTable/parseExcelRows';
export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { PeopleTextField } from './PeopleField/PeopleTextField';
export { OrganizationField } from './OrganizationField/OrganizationField';
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';