feat: несколько причин и сотрудников в заявке на подбор при 2+ вакансиях
Tests / frontend-test (push) Successful in 1m14s
Tests / frontend-test (push) Successful in 1m14s
This commit is contained in:
@@ -105,7 +105,13 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
|
||||
set('ProbationPeriodOther', selection.SelectionProbationPeriodOther);
|
||||
set('SalaryProbation', selection.RateProbation);
|
||||
set('SalaryAfterProbation', selection.RateAfterProbation);
|
||||
const hireTypePrefill = hireTypeFromSelection(selection.VacancyOpenReason, selection.VacancyOpenReasonDetails);
|
||||
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
|
||||
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
|
||||
const reasons = selection.VacancyReasons;
|
||||
const single = reasons.length === 1 ? reasons[0] : undefined;
|
||||
const hireTypePrefill = single
|
||||
? hireTypeFromSelection(single.reason, single.employees.length === 1 ? single.employees[0] : '')
|
||||
: undefined;
|
||||
if (hireTypePrefill) {
|
||||
set('HireType', hireTypePrefill.hireType);
|
||||
set('HireReplacedEmployee', hireTypePrefill.replacedEmployee);
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model';
|
||||
import { missingRequiredFields } from './validation';
|
||||
import { submitBlocker } from './validation';
|
||||
import { saveSelectionRequest, selectionApprovalService } from './submit';
|
||||
import { loadSelectionRequest } from './load';
|
||||
import { ApplicationSection } from './sections/ApplicationSection';
|
||||
@@ -267,8 +267,8 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
// Остальные поля формы обязательны только для ОТПРАВКИ на согласование —
|
||||
// черновик по-прежнему можно сохранить не заполнив их (см. validation.ts).
|
||||
const missingFields = missingRequiredFields(data);
|
||||
const isSubmitValid = isValid && missingFields.length === 0;
|
||||
const blocker = submitBlocker(data);
|
||||
const isSubmitValid = isValid && !blocker;
|
||||
|
||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||
rows.map((r) => ({
|
||||
@@ -348,9 +348,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
||||
const validateSubmit = (): string | undefined => {
|
||||
const formError = validate();
|
||||
if (formError) return formError;
|
||||
if (missingFields.length > 0) {
|
||||
return `Заполните обязательные поля: ${missingFields.join(', ')}.`;
|
||||
}
|
||||
if (blocker) return blocker;
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadSelectionRequest } from './load';
|
||||
import { selectionApprovalService, updateAllSelectionFields } from './submit';
|
||||
import { missingRequiredFields } from './validation';
|
||||
import { submitBlocker } from './validation';
|
||||
import { ApplicationSection } from './sections/ApplicationSection';
|
||||
import { DepartmentSection } from './sections/DepartmentSection';
|
||||
import { VacancySection } from './sections/VacancySection';
|
||||
@@ -184,8 +184,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
// отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel).
|
||||
const handleBeforeDecide = async (decision: ApproverDecision): Promise<void> => {
|
||||
if (decision === 'approve') {
|
||||
const missing = missingRequiredFields(data);
|
||||
if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`);
|
||||
const blocker = submitBlocker(data);
|
||||
if (blocker) throw new Error(blocker);
|
||||
}
|
||||
await saveAllFields();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IChangeTrackingConfig } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { IFormData } from './model';
|
||||
import { IChangeTrackingConfig, EMPTY_VALUE } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { IFormData, IVacancyReason } from './model';
|
||||
import { summarizeVacancyReasons } from './vacancyReasons';
|
||||
|
||||
/** Колонка журнала правок согласующего (Note, JSON) — см. provisioning.json. */
|
||||
export const APPROVER_CHANGES_FIELD = 'SelectionApproverChangesJson';
|
||||
@@ -10,5 +11,8 @@ export const APPROVER_CHANGES_FIELD = 'SelectionApproverChangesJson';
|
||||
* «Объект/Проект» и «Подразделение».
|
||||
*/
|
||||
export const SELECTION_CHANGE_TRACKING: IChangeTrackingConfig<IFormData> = {
|
||||
ignore: ['ObjectProjectId', 'ObjectProjectCode', 'DepartmentId']
|
||||
ignore: ['ObjectProjectId', 'ObjectProjectCode', 'DepartmentId'],
|
||||
formatters: {
|
||||
VacancyReasons: (value) => summarizeVacancyReasons((value as IVacancyReason[]) || []) || EMPTY_VALUE
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dataListTitle } from '../../shared/listTitles';
|
||||
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, ILanguageLevel } from './model';
|
||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||
import { parseVacancyReasons } from './vacancyReasons';
|
||||
|
||||
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
||||
function parseJsonArray<T>(raw: unknown): T[] {
|
||||
@@ -53,7 +54,8 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
|
||||
|
||||
// Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand);
|
||||
// Companies/Languages/Programs хранятся в Note-колонках *Json (не одноимёнными колонками).
|
||||
const jsonKeys: (keyof IFormData)[] = ['Companies', 'Languages', 'Programs', 'WorkplaceProvision'];
|
||||
// VacancyReasons — тоже JSON (VacancyReasonsJson) с откатом на старые колонки причины.
|
||||
const jsonKeys: (keyof IFormData)[] = ['Companies', 'Languages', 'Programs', 'WorkplaceProvision', 'VacancyReasons'];
|
||||
const scalarSelects = keys.filter((k) => !isPerson(k) && jsonKeys.indexOf(k) === -1);
|
||||
const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`);
|
||||
const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS];
|
||||
@@ -67,6 +69,9 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
|
||||
'LanguagesJson',
|
||||
'ProgramsJson',
|
||||
'WorkplaceProvisionJson',
|
||||
'VacancyReasonsJson',
|
||||
'VacancyOpenReason',
|
||||
'VacancyOpenReasonDetails',
|
||||
APPROVER_CHANGES_FIELD,
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode',
|
||||
@@ -80,6 +85,7 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
|
||||
writable.Languages = parseJsonArray<ILanguageLevel>(item.LanguagesJson);
|
||||
writable.Programs = parseJsonArray<string>(item.ProgramsJson);
|
||||
writable.WorkplaceProvision = parseJsonArray<string>(item.WorkplaceProvisionJson);
|
||||
writable.VacancyReasons = parseVacancyReasons(item.VacancyReasonsJson, item.VacancyOpenReason, item.VacancyOpenReasonDetails);
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (jsonKeys.indexOf(key) !== -1) {
|
||||
|
||||
@@ -20,14 +20,13 @@ export interface IFormData {
|
||||
SubmitDate: string;
|
||||
ClosingDeadline: string;
|
||||
/**
|
||||
* Префикс Vacancy — не для читаемости, а обход коллизии managed properties:
|
||||
* колонка называлась просто OpenReason, и Search индексировал её только у
|
||||
* ОДНОГО из списков сайта с таким internal name (см. sp-write props/field в
|
||||
* tools). Переименовали и перенесли данные скриптом (2026-08-29) — трогать
|
||||
* имя обратно нельзя, коллизия вернётся.
|
||||
* 1.5 Причины открытия вакансии. Одна причина — при одной вакансии; при 2.2 > 1
|
||||
* можно несколько, и у причин с ФИО — несколько сотрудников. Хранится JSON в
|
||||
* колонке VacancyReasonsJson; старые колонки VacancyOpenReason (первая
|
||||
* причина) и VacancyOpenReasonDetails (сводка текстом) заполняются для
|
||||
* совместимости — см. vacancyReasons.ts.
|
||||
*/
|
||||
VacancyOpenReason: string;
|
||||
VacancyOpenReasonDetails: string;
|
||||
VacancyReasons: IVacancyReason[];
|
||||
Companies: ICompany[];
|
||||
ObjectProject: string;
|
||||
ObjectProjectId: string;
|
||||
@@ -84,6 +83,16 @@ export interface IFormData {
|
||||
HiringDecisionMaker: string[];
|
||||
}
|
||||
|
||||
/** Одна причина открытия вакансии (п. 1.5). */
|
||||
export interface IVacancyReason {
|
||||
/** Значение из OPEN_REASON_OPTIONS. */
|
||||
reason: string;
|
||||
/** ФИО сотрудников — для причин с ФИО (замена, перевод, декрет); иначе []. */
|
||||
employees: string[];
|
||||
/** Уточнение — для «Другое»; иначе ''. */
|
||||
details: string;
|
||||
}
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
@@ -104,8 +113,7 @@ export const EMPTY_DATA: IFormData = {
|
||||
Responsible: '',
|
||||
SubmitDate: '',
|
||||
ClosingDeadline: '',
|
||||
VacancyOpenReason: '',
|
||||
VacancyOpenReasonDetails: '',
|
||||
VacancyReasons: [],
|
||||
Companies: [],
|
||||
ObjectProject: '',
|
||||
ObjectProjectId: '',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Декрет", "Другое"] },
|
||||
{ "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
|
||||
{ "internalName": "VacancyReasonsJson", "title": "Причины открытия вакансии (JSON)", "type": "Note" },
|
||||
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
|
||||
{ "internalName": "SelectionApproverChangesJson", "title": "Изменения согласующего — подбор (JSON)", "type": "Note" },
|
||||
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
|
||||
|
||||
+15
-38
@@ -1,26 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, Select, PeopleField } from '../../../shared/ui';
|
||||
import { CollapseSection, FormField, TextInput, PeopleField } from '../../../shared/ui';
|
||||
import { CompanyPickerMulti, ProjectPicker } from '../../../shared/dictionary';
|
||||
import {
|
||||
ISectionProps,
|
||||
PERSON_PLACEHOLDER,
|
||||
OPEN_REASON_OPTIONS,
|
||||
OPEN_REASON_REPLACE,
|
||||
OPEN_REASON_TRANSFER,
|
||||
OPEN_REASON_NEW,
|
||||
OPEN_REASON_MATERNITY
|
||||
} from '../model';
|
||||
|
||||
/** Причины, где вакансия открывается под конкретного сотрудника — спрашиваем его ФИО. */
|
||||
const REASONS_WITH_EMPLOYEE_NAME = [OPEN_REASON_REPLACE, OPEN_REASON_TRANSFER, OPEN_REASON_MATERNITY];
|
||||
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
|
||||
import { parseVacanciesCount } from '../vacancyReasons';
|
||||
import { VacancyReasonsField } from './VacancyReasonsField';
|
||||
|
||||
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context, onResponsibleChange }) => {
|
||||
const reason = data.VacancyOpenReason;
|
||||
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
|
||||
const reasonDetailsLabel =
|
||||
REASONS_WITH_EMPLOYEE_NAME.indexOf(reason) !== -1
|
||||
? 'ФИО сотрудника (Полностью, пример: Иванов Иван Иванович)'
|
||||
: 'Уточните причину';
|
||||
const vacanciesCount = parseVacanciesCount(data.OpenVacanciesCount);
|
||||
|
||||
return (
|
||||
<CollapseSection title="1. Заявка" icon="clipboard">
|
||||
@@ -52,27 +38,18 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
||||
min={data.SubmitDate || undefined}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason" required>
|
||||
<Select
|
||||
id="VacancyOpenReason"
|
||||
value={data.VacancyOpenReason}
|
||||
onChange={(v) => {
|
||||
set('VacancyOpenReason', v);
|
||||
if (v === '' || v === OPEN_REASON_NEW) set('VacancyOpenReasonDetails', '');
|
||||
}}
|
||||
options={OPEN_REASON_OPTIONS}
|
||||
placeholder="— выберите —"
|
||||
<FormField
|
||||
label={vacanciesCount > 1 ? '1.5 Причины открытия вакансий' : '1.5 Причина открытия вакансии'}
|
||||
fieldKey="VacancyReasons"
|
||||
required
|
||||
full
|
||||
>
|
||||
<VacancyReasonsField
|
||||
value={data.VacancyReasons}
|
||||
onChange={(v) => set('VacancyReasons', v)}
|
||||
vacanciesCount={vacanciesCount}
|
||||
/>
|
||||
</FormField>
|
||||
{showReasonDetails && (
|
||||
<FormField label={reasonDetailsLabel} htmlFor="VacancyOpenReasonDetails">
|
||||
<TextInput
|
||||
id="VacancyOpenReasonDetails"
|
||||
value={data.VacancyOpenReasonDetails}
|
||||
onChange={(v) => set('VacancyOpenReasonDetails', v)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="1.6 Организация" htmlFor="Companies" required full>
|
||||
<CompanyPickerMulti
|
||||
context={context}
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import { IconButton } from '@tnsu/ui-kit';
|
||||
import styles from './VacancyReasons.module.scss';
|
||||
import { Button, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
|
||||
import { IVacancyReason } from '../model';
|
||||
import { needsDetails, needsEmployeeName } from '../vacancyReasons';
|
||||
|
||||
interface IVacancyReasonDetailsProps {
|
||||
entry: IVacancyReason;
|
||||
onChange: (entry: IVacancyReason) => void;
|
||||
/** Несколько сотрудников на причину — только при 2.2 > 1. */
|
||||
allowMultipleEmployees: boolean;
|
||||
/** Префикс id полей — уникален в пределах формы. */
|
||||
idPrefix: string;
|
||||
}
|
||||
|
||||
const EMPLOYEE_PLACEHOLDER = 'ФИО полностью, пример: Иванов Иван Иванович';
|
||||
|
||||
/**
|
||||
* Что уточняется под причиной: ФИО сотрудников (замена/перевод/декрет) или
|
||||
* текст «Уточните причину» («Другое»). У «Новой штатной единицы» — ничего.
|
||||
*/
|
||||
export const VacancyReasonDetails: React.FC<IVacancyReasonDetailsProps> = ({
|
||||
entry,
|
||||
onChange,
|
||||
allowMultipleEmployees,
|
||||
idPrefix
|
||||
}) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
if (needsDetails(entry.reason)) {
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<label className={styles.detailsLabel} htmlFor={`${idPrefix}-details`}>
|
||||
Уточните причину
|
||||
</label>
|
||||
<TextInput
|
||||
id={`${idPrefix}-details`}
|
||||
value={entry.details}
|
||||
onChange={(v) => onChange({ ...entry, details: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!needsEmployeeName(entry.reason)) return null;
|
||||
|
||||
// Хотя бы одна строка ввода — даже если сотрудников ещё нет.
|
||||
const rows = entry.employees.length > 0 ? entry.employees : [''];
|
||||
const setRow = (index: number, value: string): void =>
|
||||
onChange({ ...entry, employees: rows.map((e, i) => (i === index ? value : e)) });
|
||||
const removeRow = (index: number): void =>
|
||||
onChange({ ...entry, employees: rows.filter((_, i) => i !== index) });
|
||||
const addRow = (): void => onChange({ ...entry, employees: [...rows, ''] });
|
||||
// Убрать лишнего можно всегда (вакансий могли уменьшить), добавить — только при 2.2 > 1.
|
||||
const canRemove = !readOnly && rows.length > 1;
|
||||
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<span className={styles.detailsLabel}>
|
||||
{rows.length > 1 || allowMultipleEmployees ? 'ФИО сотрудников' : 'ФИО сотрудника'}
|
||||
</span>
|
||||
{rows.map((employee, i) => (
|
||||
<div key={i} className={styles.employeeRow}>
|
||||
<TextInput
|
||||
id={`${idPrefix}-employee-${i}`}
|
||||
value={employee}
|
||||
onChange={(v) => setRow(i, v)}
|
||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||
ariaLabel={`ФИО сотрудника ${i + 1}`}
|
||||
/>
|
||||
{canRemove && (
|
||||
<IconButton icon="trash" title="Убрать сотрудника" danger size="sm" onClick={() => removeRow(i)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{allowMultipleEmployees && !readOnly && (
|
||||
<Button variant="secondary" size="sm" className={styles.addButton} onClick={addRow}>
|
||||
<Icon name="plus" size="sm" />
|
||||
Добавить сотрудника
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reason {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
// Сотрудники/уточнение — с отступом под своей причиной, чтобы было видно, к какой относятся.
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
padding-left: $sp-6;
|
||||
}
|
||||
|
||||
.detailsLabel {
|
||||
font-size: $fs-label;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.employeeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
|
||||
> :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.addButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: $fs-sm;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.warning {
|
||||
font-size: $fs-sm;
|
||||
color: $gold-600;
|
||||
}
|
||||
|
||||
// На узком экране отступ вложенного блока съедает ширину — уменьшаем.
|
||||
@media (max-width: 480px) {
|
||||
.details {
|
||||
padding-left: $sp-3;
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import styles from './VacancyReasons.module.scss';
|
||||
import { Checkbox, Select, useFormReadOnly } from '../../../shared/ui';
|
||||
import { IVacancyReason, OPEN_REASON_OPTIONS } from '../model';
|
||||
import { allowsMultipleReasons, emptyReason, vacancyReasonsError, vacancyReasonsWarning } from '../vacancyReasons';
|
||||
import { VacancyReasonDetails } from './VacancyReasonDetails';
|
||||
|
||||
interface IVacancyReasonsFieldProps {
|
||||
value: IVacancyReason[];
|
||||
onChange: (reasons: IVacancyReason[]) => void;
|
||||
/** Число из п. 2.2 — от него зависит, можно ли выбрать несколько причин. */
|
||||
vacanciesCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.5 Причины открытия вакансии.
|
||||
*
|
||||
* Одна вакансия — выпадающий список, одна причина, один сотрудник. Больше одной
|
||||
* — флажки: несколько причин, у причин с ФИО — несколько сотрудников. Если
|
||||
* вакансий стало меньше, а причин выбрано несколько, остаются флажки с
|
||||
* подсказкой (ничего не стираем молча).
|
||||
*/
|
||||
export const VacancyReasonsField: React.FC<IVacancyReasonsFieldProps> = ({ value, onChange, vacanciesCount }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
const multiple = allowsMultipleReasons(vacanciesCount);
|
||||
const showCheckboxes = multiple || value.length > 1;
|
||||
|
||||
const entryOf = (reason: string): IVacancyReason | undefined => value.find((r) => r.reason === reason);
|
||||
const replaceEntry = (entry: IVacancyReason): void =>
|
||||
onChange(value.map((r) => (r.reason === entry.reason ? entry : r)));
|
||||
|
||||
// Порядок причин — как в списке вариантов, а не в порядке кликов.
|
||||
const toggle = (reason: string, checked: boolean): void => {
|
||||
const selected = OPEN_REASON_OPTIONS.map((o) => o.value).filter((v) =>
|
||||
v === reason ? checked : !!entryOf(v)
|
||||
);
|
||||
onChange(selected.map((v) => entryOf(v) ?? emptyReason(v)));
|
||||
};
|
||||
|
||||
const error = vacancyReasonsError(value, vacanciesCount);
|
||||
const warning = vacancyReasonsWarning(value, vacanciesCount);
|
||||
const messages = (
|
||||
<>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{!error && warning && <div className={styles.warning}>{warning}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!showCheckboxes) {
|
||||
const current = value[0];
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<Select
|
||||
id="VacancyOpenReason"
|
||||
value={current?.reason ?? ''}
|
||||
onChange={(v) => onChange(v ? [entryOf(v) ?? emptyReason(v)] : [])}
|
||||
options={OPEN_REASON_OPTIONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
{current && (
|
||||
<VacancyReasonDetails
|
||||
entry={current}
|
||||
onChange={replaceEntry}
|
||||
allowMultipleEmployees={false}
|
||||
idPrefix="VacancyReason-0"
|
||||
/>
|
||||
)}
|
||||
{messages}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// В режиме чтения показываем только выбранные причины — список всех флажков там лишний.
|
||||
const options = readOnly ? OPEN_REASON_OPTIONS.filter((o) => !!entryOf(o.value)) : OPEN_REASON_OPTIONS;
|
||||
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
{options.map((option, i) => {
|
||||
const entry = entryOf(option.value);
|
||||
return (
|
||||
<div key={option.value} className={styles.reason}>
|
||||
<Checkbox
|
||||
id={`VacancyReason-${i}`}
|
||||
checked={!!entry}
|
||||
onChange={(checked) => toggle(option.value, checked)}
|
||||
label={option.label}
|
||||
/>
|
||||
{entry && (
|
||||
<VacancyReasonDetails
|
||||
entry={entry}
|
||||
onChange={replaceEntry}
|
||||
allowMultipleEmployees={multiple}
|
||||
idPrefix={`VacancyReason-${i}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{messages}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/f
|
||||
import { buildRequestTitle } from '../../shared/requestTitle';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||
import { cleanVacancyReasons, legacyDetailsText } from './vacancyReasons';
|
||||
|
||||
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
||||
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
||||
@@ -55,6 +56,14 @@ function buildScalarBody(data: IFormData): Record<string, unknown> {
|
||||
item.ProgramsJson = data.Programs.length > 0 ? JSON.stringify(data.Programs) : null;
|
||||
return;
|
||||
}
|
||||
if (key === 'VacancyReasons') {
|
||||
// Полная структура — JSON; старые колонки — для совместимости (см. vacancyReasons.ts).
|
||||
const reasons = cleanVacancyReasons(data.VacancyReasons);
|
||||
item.VacancyReasonsJson = reasons.length > 0 ? JSON.stringify(reasons) : null;
|
||||
item.VacancyOpenReason = reasons.length > 0 ? reasons[0].reason : null;
|
||||
item.VacancyOpenReasonDetails = legacyDetailsText(reasons) || null;
|
||||
return;
|
||||
}
|
||||
if (key === 'WorkplaceProvision') {
|
||||
// Мультивыбор категорий обеспечения — сериализуем в Note.
|
||||
item.WorkplaceProvisionJson = data.WorkplaceProvision.length > 0 ? JSON.stringify(data.WorkplaceProvision) : null;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
parseVacancyReasons,
|
||||
summarizeVacancyReasons,
|
||||
legacyDetailsText,
|
||||
vacancyReasonsError,
|
||||
vacancyReasonsWarning,
|
||||
parseVacanciesCount,
|
||||
cleanVacancyReasons
|
||||
} from './vacancyReasons';
|
||||
|
||||
const replace = { reason: 'Замена уволенного сотрудника', employees: ['Иванов И. И.', 'Петров П. П.'], details: '' };
|
||||
const newUnit = { reason: 'Новая штатная единица', employees: [], details: '' };
|
||||
|
||||
describe('parseVacancyReasons', () => {
|
||||
it('читает JSON новой колонки', () => {
|
||||
expect(parseVacancyReasons(JSON.stringify([replace, newUnit]), '', '')).toEqual([replace, newUnit]);
|
||||
});
|
||||
|
||||
it('старая заявка: причина с ФИО → один сотрудник', () => {
|
||||
expect(parseVacancyReasons(null, 'Декрет', 'Иванова А. И.')).toEqual([
|
||||
{ reason: 'Декрет', employees: ['Иванова А. И.'], details: '' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('старая заявка: «Другое» → уточнение', () => {
|
||||
expect(parseVacancyReasons('', 'Другое', 'расширение штата')).toEqual([
|
||||
{ reason: 'Другое', employees: [], details: 'расширение штата' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('нет ни JSON, ни старой причины — пусто', () => {
|
||||
expect(parseVacancyReasons(null, null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('сводка для старых колонок', () => {
|
||||
it('склеивает причины и людей', () => {
|
||||
expect(summarizeVacancyReasons([replace, newUnit])).toBe(
|
||||
'Замена уволенного сотрудника: Иванов И. И., Петров П. П.; Новая штатная единица'
|
||||
);
|
||||
});
|
||||
|
||||
it('обрезает под лимит текстовой колонки SP', () => {
|
||||
const many = { ...replace, employees: Array.from({ length: 40 }, (_, i) => `Сотрудник ${i}`) };
|
||||
const text = legacyDetailsText([many]);
|
||||
expect(text.length).toBe(255);
|
||||
expect(text.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('чистит пустые ФИО и чужие поля причины', () => {
|
||||
expect(cleanVacancyReasons([{ reason: 'Новая штатная единица', employees: ['x'], details: 'y' }])).toEqual([newUnit]);
|
||||
expect(cleanVacancyReasons([{ ...replace, employees: [' Иванов ', ''] }])[0].employees).toEqual(['Иванов']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('проверки', () => {
|
||||
it('несколько причин при одной вакансии — ошибка', () => {
|
||||
expect(vacancyReasonsError([replace, newUnit], 1)).toBeDefined();
|
||||
expect(vacancyReasonsError([replace, newUnit], 3)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('сотрудников больше, чем вакансий — предупреждение', () => {
|
||||
expect(vacancyReasonsWarning([replace], 1)).toBeDefined();
|
||||
expect(vacancyReasonsWarning([replace], 2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('разбирает количество вакансий', () => {
|
||||
expect(parseVacanciesCount('3')).toBe(3);
|
||||
expect(parseVacanciesCount('')).toBe(0);
|
||||
expect(parseVacanciesCount('-2')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
IVacancyReason,
|
||||
OPEN_REASON_MATERNITY,
|
||||
OPEN_REASON_REPLACE,
|
||||
OPEN_REASON_TRANSFER,
|
||||
OTHER_VALUE
|
||||
} from './model';
|
||||
|
||||
/**
|
||||
* Причины открытия вакансии (п. 1.5): несколько причин при 2.2 > 1 и несколько
|
||||
* сотрудников на причину. Логика без UI — разбор/сводка/проверки.
|
||||
*
|
||||
* Хранение: полная структура — JSON в VacancyReasonsJson (из неё читает
|
||||
* портал). Для совместимости (список SP напрямую, старые отчёты) пишутся и
|
||||
* старые колонки: VacancyOpenReason — ПЕРВАЯ причина (Choice, одно значение),
|
||||
* VacancyOpenReasonDetails — всё целиком текстом (см. summarizeVacancyReasons).
|
||||
* Имена старых колонок не менять: префикс Vacancy — обход коллизии managed
|
||||
* properties Search (переименовали и перенесли данные 2026-08-29).
|
||||
*/
|
||||
|
||||
/** Причины, где вакансия открывается под конкретных сотрудников — спрашиваем их ФИО. */
|
||||
const REASONS_WITH_EMPLOYEE_NAME: ReadonlyArray<string> = [
|
||||
OPEN_REASON_REPLACE,
|
||||
OPEN_REASON_TRANSFER,
|
||||
OPEN_REASON_MATERNITY
|
||||
];
|
||||
|
||||
/** Лимит колонки «Однострочный текст» SharePoint — длиннее запись упадёт. */
|
||||
const LEGACY_TEXT_MAX = 255;
|
||||
|
||||
export function needsEmployeeName(reason: string): boolean {
|
||||
return REASONS_WITH_EMPLOYEE_NAME.indexOf(reason) !== -1;
|
||||
}
|
||||
|
||||
export function needsDetails(reason: string): boolean {
|
||||
return reason === OTHER_VALUE;
|
||||
}
|
||||
|
||||
/** Новая запись причины — без людей и уточнения. */
|
||||
export function emptyReason(reason: string): IVacancyReason {
|
||||
return { reason, employees: [], details: '' };
|
||||
}
|
||||
|
||||
function isVacancyReason(item: unknown): item is IVacancyReason {
|
||||
const record = item as Record<string, unknown> | null;
|
||||
return !!record && typeof record.reason === 'string' && Array.isArray(record.employees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Причины из элемента списка: JSON новой колонки, а если его нет (заявки до
|
||||
* мультипричин) — из старых колонок «причина» + «ФИО / уточнение».
|
||||
*/
|
||||
export function parseVacancyReasons(json: unknown, legacyReason: unknown, legacyDetails: unknown): IVacancyReason[] {
|
||||
if (typeof json === 'string' && json.trim() !== '') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter(isVacancyReason).map((r) => ({
|
||||
reason: r.reason,
|
||||
employees: r.employees.filter((e): e is string => typeof e === 'string'),
|
||||
details: typeof r.details === 'string' ? r.details : ''
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// битый JSON — падаем на старые колонки ниже
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof legacyReason !== 'string' || legacyReason === '') return [];
|
||||
const details = typeof legacyDetails === 'string' ? legacyDetails.trim() : '';
|
||||
if (needsEmployeeName(legacyReason)) {
|
||||
return [{ reason: legacyReason, employees: details ? [details] : [], details: '' }];
|
||||
}
|
||||
return [{ reason: legacyReason, employees: [], details: needsDetails(legacyReason) ? details : '' }];
|
||||
}
|
||||
|
||||
/** Перед записью: без пустых ФИО и лишних пробелов, у каждой причины — только её поля. */
|
||||
export function cleanVacancyReasons(reasons: IVacancyReason[]): IVacancyReason[] {
|
||||
return reasons.map((r) => ({
|
||||
reason: r.reason,
|
||||
employees: needsEmployeeName(r.reason) ? r.employees.map((e) => e.trim()).filter(Boolean) : [],
|
||||
details: needsDetails(r.reason) ? r.details.trim() : ''
|
||||
}));
|
||||
}
|
||||
|
||||
/** Все причины одной строкой: «Замена уволенного сотрудника: Иванов, Петров; Новая штатная единица». */
|
||||
export function summarizeVacancyReasons(reasons: IVacancyReason[]): string {
|
||||
return cleanVacancyReasons(reasons)
|
||||
.map((r) => {
|
||||
const extra = r.employees.length > 0 ? r.employees.join(', ') : r.details;
|
||||
return extra ? `${r.reason}: ${extra}` : r.reason;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** Сводка для старой текстовой колонки — обрезана под её лимит. */
|
||||
export function legacyDetailsText(reasons: IVacancyReason[]): string {
|
||||
const summary = summarizeVacancyReasons(reasons);
|
||||
return summary.length > LEGACY_TEXT_MAX ? `${summary.slice(0, LEGACY_TEXT_MAX - 1)}…` : summary;
|
||||
}
|
||||
|
||||
/** Число из поля 2.2; пусто/мусор — 0. */
|
||||
export function parseVacanciesCount(raw: string): number {
|
||||
const n = Number(raw);
|
||||
return isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
||||
}
|
||||
|
||||
/** Можно ли выбирать несколько причин и сотрудников (п. 2.2 больше одной). */
|
||||
export function allowsMultipleReasons(vacanciesCount: number): boolean {
|
||||
return vacanciesCount > 1;
|
||||
}
|
||||
|
||||
/** Блокирующая проблема для отправки: несколько причин при одной вакансии. */
|
||||
export function vacancyReasonsError(reasons: IVacancyReason[], vacanciesCount: number): string | undefined {
|
||||
if (!allowsMultipleReasons(vacanciesCount) && reasons.length > 1) {
|
||||
return 'При одной вакансии оставьте одну причину открытия (п. 1.5) или увеличьте количество вакансий (п. 2.2).';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Предупреждение (отправку не блокирует): сотрудников указано больше, чем вакансий. */
|
||||
export function vacancyReasonsWarning(reasons: IVacancyReason[], vacanciesCount: number): string | undefined {
|
||||
const employees = cleanVacancyReasons(reasons).reduce((sum, r) => sum + r.employees.length, 0);
|
||||
if (vacanciesCount > 0 && employees > vacanciesCount) {
|
||||
return `Указано сотрудников: ${employees}, а вакансий: ${vacanciesCount}. Проверьте п. 1.5 и 2.2.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IFormData } from './model';
|
||||
import { parseVacanciesCount, vacancyReasonsError } from './vacancyReasons';
|
||||
|
||||
/**
|
||||
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему
|
||||
@@ -16,7 +17,7 @@ import { IFormData } from './model';
|
||||
const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [
|
||||
{ label: '1.3 дата подачи заявки', ok: (d) => !!d.SubmitDate },
|
||||
{ label: '1.4 крайний срок закрытия', ok: (d) => !!d.ClosingDeadline },
|
||||
{ label: '1.5 причина открытия вакансии', ok: (d) => !!d.VacancyOpenReason },
|
||||
{ label: '1.5 причина открытия вакансии', ok: (d) => d.VacancyReasons.length > 0 },
|
||||
{ label: '1.6 организация', ok: (d) => d.Companies.length > 0 },
|
||||
{ label: '2.1 подразделение', ok: (d) => !!d.Department.trim() },
|
||||
{ label: '2.2 количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() },
|
||||
@@ -40,3 +41,14 @@ const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => bool
|
||||
export function missingRequiredFields(data: IFormData): string[] {
|
||||
return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Всё, что мешает отправке/согласованию, одной строкой: сначала незаполненные
|
||||
* обязательные поля, затем согласованность причин с количеством вакансий.
|
||||
* undefined — отправлять можно.
|
||||
*/
|
||||
export function submitBlocker(data: IFormData): string | undefined {
|
||||
const missing = missingRequiredFields(data);
|
||||
if (missing.length > 0) return `Заполните обязательные поля: ${missing.join(', ')}.`;
|
||||
return vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user