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