From 7959d91025f84b7c06a13b1caaaaf9a0d0b3792e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 25 Sep 2026 10:33:05 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=202+=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8?= =?UTF-8?q?=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sections/SelectionRequestField.tsx | 8 +- .../RecruitingSelectionForm.tsx | 10 +- .../RecruitingSelectionView.tsx | 6 +- .../RecruitingSelection/changeTracking.ts | 10 +- .../forms/RecruitingSelection/load.ts | 8 +- .../forms/RecruitingSelection/model.ts | 26 ++-- .../RecruitingSelection/provisioning.json | 1 + .../sections/ApplicationSection.tsx | 53 ++------ .../sections/VacancyReasonDetails.tsx | 85 ++++++++++++ .../sections/VacancyReasons.module.scss | 59 ++++++++ .../sections/VacancyReasonsField.tsx | 102 ++++++++++++++ .../forms/RecruitingSelection/submit.ts | 9 ++ .../vacancyReasons.test.ts | 72 ++++++++++ .../RecruitingSelection/vacancyReasons.ts | 128 ++++++++++++++++++ .../forms/RecruitingSelection/validation.ts | 14 +- 15 files changed, 529 insertions(+), 62 deletions(-) create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/sections/VacancyReasonDetails.tsx create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/sections/VacancyReasons.module.scss create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/sections/VacancyReasonsField.tsx create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/vacancyReasons.test.ts create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/vacancyReasons.ts diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx index 12b2795..3f94329 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx @@ -105,7 +105,13 @@ export const SelectionRequestField: React.FC = ({ 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); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 65407b1..262ff0d 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -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 = ({ // Остальные поля формы обязательны только для ОТПРАВКИ на согласование — // черновик по-прежнему можно сохранить не заполнив их (см. 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 = ({ 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}. Попробуйте ещё раз или обратитесь к администратору.`; } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index c84c3d7..5b7d700 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -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 = ({ cont // отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel). const handleBeforeDecide = async (decision: ApproverDecision): Promise => { 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(); }; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/changeTracking.ts b/src/webparts/requestPortal/forms/RecruitingSelection/changeTracking.ts index 04f0345..e6b793b 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/changeTracking.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/changeTracking.ts @@ -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 = { - ignore: ['ObjectProjectId', 'ObjectProjectCode', 'DepartmentId'] + ignore: ['ObjectProjectId', 'ObjectProjectCode', 'DepartmentId'], + formatters: { + VacancyReasons: (value) => summarizeVacancyReasons((value as IVacancyReason[]) || []) || EMPTY_VALUE + } }; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts index bb109d7..27a2848 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts @@ -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(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(item.LanguagesJson); writable.Programs = parseJsonArray(item.ProgramsJson); writable.WorkplaceProvision = parseJsonArray(item.WorkplaceProvisionJson); + writable.VacancyReasons = parseVacancyReasons(item.VacancyReasonsJson, item.VacancyOpenReason, item.VacancyOpenReasonDetails); keys.forEach((key) => { if (jsonKeys.indexOf(key) !== -1) { diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/model.ts b/src/webparts/requestPortal/forms/RecruitingSelection/model.ts index 84e46c5..551080e 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/model.ts @@ -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 = (key: K, value: IFormData[K]) => void; @@ -104,8 +113,7 @@ export const EMPTY_DATA: IFormData = { Responsible: '', SubmitDate: '', ClosingDeadline: '', - VacancyOpenReason: '', - VacancyOpenReasonDetails: '', + VacancyReasons: [], Companies: [], ObjectProject: '', ObjectProjectId: '', diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json index 02eaa90..30b3522 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json @@ -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" }, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx index 2107bdd..586787a 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx @@ -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 = ({ 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 ( @@ -52,27 +38,18 @@ export const ApplicationSection: React.FC = ({ data, set, context min={data.SubmitDate || undefined} /> - - onChange(v ? [entryOf(v) ?? emptyReason(v)] : [])} + options={OPEN_REASON_OPTIONS} + placeholder="— выберите —" + /> + {current && ( + + )} + {messages} + + ); + } + + // В режиме чтения показываем только выбранные причины — список всех флажков там лишний. + const options = readOnly ? OPEN_REASON_OPTIONS.filter((o) => !!entryOf(o.value)) : OPEN_REASON_OPTIONS; + + return ( +
+ {options.map((option, i) => { + const entry = entryOf(option.value); + return ( +
+ toggle(option.value, checked)} + label={option.label} + /> + {entry && ( + + )} +
+ ); + })} + {messages} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 8b1950c..c123a1d 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -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 в колонку Id. const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager']; @@ -55,6 +56,14 @@ function buildScalarBody(data: IFormData): Record { 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; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/vacancyReasons.test.ts b/src/webparts/requestPortal/forms/RecruitingSelection/vacancyReasons.test.ts new file mode 100644 index 0000000..df39504 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/vacancyReasons.test.ts @@ -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); + }); +}); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/vacancyReasons.ts b/src/webparts/requestPortal/forms/RecruitingSelection/vacancyReasons.ts new file mode 100644 index 0000000..d3e3c5f --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/vacancyReasons.ts @@ -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 = [ + 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 | 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; +} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/validation.ts b/src/webparts/requestPortal/forms/RecruitingSelection/validation.ts index 9c8c2b3..28a4dc0 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/validation.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/validation.ts @@ -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)); +}