feat: выбор нескольких проектов в заявке на приём
Tests / frontend-test (push) Successful in 2m2s

Проект больше не влияет на маршрут (условие по Project убрано в админке),
поэтому поле стало мультивыбором (ProjectPickerMulti). Список хранится в
новой колонке ProjectsJson; старые Project/ProjectId/ProjectCode заполняются
производными значениями для представлений и отчётов, старые заявки читаются
из них. Title и текстовые колонки обрезаются до 255 символов, чтобы длинные
названия не роняли сохранение. Требует провижининга колонки ProjectsJson.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-08 12:39:14 +05:00
parent cfe97dc67e
commit 57efa7620e
17 changed files with 471 additions and 51 deletions
@@ -154,11 +154,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id; const requestTypeId = requestTypeIdState.id;
// Маршрут для этого типа заявки зависит от проекта (ByProject) — без projectId // Без projectId бэкенд отвечал resolve: 404 «…и признака «по проекту» не
// бэкенд отвечает resolve: 404 «…и признака «по проекту» не найден» (поле // найден». Сейчас в маршруте условий по проекту нет, но проверка projectId на
// Project изначально задумывалось справочным, но выяснилось, что маршрут // бэкенде не подтверждена снятой — поэтому передаём ПЕРВЫЙ выбранный проект
// всё-таки требует projectId, как в RecruitingSelection/TmcTransfer). // (поле — мультивыбор) и ждём его выбора, как раньше.
const projectId = data.ProjectId || null; const projectId = data.Projects.length > 0 ? data.Projects[0].Id : null;
const matrixReady = !!editId || (!!projectId && !!requestTypeId); const matrixReady = !!editId || (!!projectId && !!requestTypeId);
// Несколько шагов маршрута условны по компании (см. панель «Маршруты»: // Несколько шагов маршрута условны по компании (см. панель «Маршруты»:
// «CompaniesJson = TOO QAZGEOMAX», «CompaniesJson = TOO CT Global», шаг с // «CompaniesJson = TOO QAZGEOMAX», «CompaniesJson = TOO CT Global», шаг с
@@ -166,15 +166,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
// fieldValues, value — имена выбранных компаний ЧЕРЕЗ ПРОБЕЛ (не JSON, не // fieldValues, value — имена выбранных компаний ЧЕРЕЗ ПРОБЕЛ (не JSON, не
// массив — ResolveFieldValueDto на бэкенде принимает только строку, массив // массив — ResolveFieldValueDto на бэкенде принимает только строку, массив
// даёт 400; сериализованный JSON не совпадает с условием при сравнении). // даёт 400; сериализованный JSON не совпадает с условием при сравнении).
// Плюс шаг «Роль: Планировщик» условен по названию проекта («Project не // Условие по названию проекта («Project не содержит офис») из маршрута убрано,
// содержит офис») — сюда идёт ТЕКСТ (data.Project), а не projectId (GUID), // но Project передаём по-прежнему — на случай, если такое условие вернут в
// который уже передаётся отдельным параметром resolve. // админке: ТЕКСТ названий всех проектов через пробел (как у компаний), а не
// GUID — тот уходит отдельным параметром resolve.
const companiesFieldValue = data.Companies.map((c) => c.Name).sort().join(' '); const companiesFieldValue = data.Companies.map((c) => c.Name).sort().join(' ');
const projectsFieldValue = data.Projects.map((p) => p.Name).join(' ');
const fieldValues = useMemo(() => { const fieldValues = useMemo(() => {
const companies = companiesFieldValue ? [{ fieldName: 'CompaniesJson', value: companiesFieldValue }] : []; const companies = companiesFieldValue ? [{ fieldName: 'CompaniesJson', value: companiesFieldValue }] : [];
return data.Project ? [...companies, { fieldName: 'Project', value: data.Project }] : companies; return projectsFieldValue ? [...companies, { fieldName: 'Project', value: projectsFieldValue }] : companies;
// eslint-disable-next-line react-hooks/exhaustive-deps -- companiesFieldValue — стабильный слепок data.Companies }, [companiesFieldValue, projectsFieldValue]);
}, [companiesFieldValue, data.Project]);
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
editId editId
@@ -12,12 +12,12 @@ function companyName(data: IFormData, companyId: string): string {
/** /**
* Что пишем в журнал правок согласующего (shared/fieldChanges). * Что пишем в журнал правок согласующего (shared/fieldChanges).
* *
* Без своего поля на форме (не пишем): Id/код проекта — меняются вместе с * Без своего поля на форме (не пишем): связь с подбором — меняется вместе с её
* «Проект»; связь с подбором — вместе с её кодом (1.1); HireOnArrival — флажок * кодом (1.1); HireOnArrival — флажок внутри поля 2.2, его правку видно по
* внутри поля 2.2, его правку видно по самой дате. * самой дате. Проекты (Projects) пишутся как есть — списком названий.
*/ */
export const HIRE_CHANGE_TRACKING: IChangeTrackingConfig<IFormData> = { export const HIRE_CHANGE_TRACKING: IChangeTrackingConfig<IFormData> = {
ignore: ['ProjectId', 'ProjectCode', 'SelectionRequestId', 'SelectionRequestListId', 'HireOnArrival'], ignore: ['SelectionRequestId', 'SelectionRequestListId', 'HireOnArrival'],
formatters: { formatters: {
MainCompanyId: (value, data) => (value ? companyName(data, String(value)) : EMPTY_VALUE), MainCompanyId: (value, data) => (value ? companyName(data, String(value)) : EMPTY_VALUE),
CompanyShares: (value, data) => { CompanyShares: (value, data) => {
@@ -5,6 +5,7 @@ import { spDateOnlyToIso } from '../../shared/dateUtils';
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare, LEGACY_HIRE_TYPES } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare, LEGACY_HIRE_TYPES } from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking'; import { APPROVER_CHANGES_FIELD } from './changeTracking';
import { projectsFromItem } from './projects';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */ /** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
function parseJsonArray<T>(raw: unknown): T[] { function parseJsonArray<T>(raw: unknown): T[] {
@@ -20,7 +21,7 @@ function parseJsonArray<T>(raw: unknown): T[] {
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке. // Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
const SCALAR_FIELDS: (keyof IFormData)[] = [ const SCALAR_FIELDS: (keyof IFormData)[] = [
'MainCompanyId', 'MainCompanyId',
'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode', 'Phone', 'HireType', 'HireReplacedEmployee',
'HireCandidateName', 'HireHireDate', 'Position', 'HireCandidateName', 'HireHireDate', 'Position',
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther', 'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
@@ -54,6 +55,11 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
...SCALAR_FIELDS, ...SCALAR_FIELDS,
'CompaniesJson', 'CompaniesJson',
'CompanySharesJson', 'CompanySharesJson',
// Старые одиночные колонки — для заявок, поданных до мультивыбора (см. projects.ts).
'ProjectsJson',
'Project',
'ProjectId',
'ProjectCode',
'RecruitingAgency', 'RecruitingAgency',
'HireOnArrival', 'HireOnArrival',
APPROVER_CHANGES_FIELD, APPROVER_CHANGES_FIELD,
@@ -68,6 +74,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
(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. // % ставки по компаниям (совмещение) — аналогично, Note-колонка CompanySharesJson.
(data as unknown as Record<string, unknown>).CompanyShares = parseJsonArray<ICompanyShare>(item.CompanySharesJson); (data as unknown as Record<string, unknown>).CompanyShares = parseJsonArray<ICompanyShare>(item.CompanySharesJson);
data.Projects = projectsFromItem(item);
// Флаг агентства — булево поле (Yes/No), не строка. // Флаг агентства — булево поле (Yes/No), не строка.
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency; (data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival; (data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
@@ -1,6 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../shared/ui'; import { ISelectOption } from '../../shared/ui';
import { ICompany } from '../../shared/dictionary'; import { ICompany, IProjectRef } from '../../shared/dictionary';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
/** /**
@@ -38,11 +38,14 @@ export interface IFormData {
* там имя осталось прежним. * там имя осталось прежним.
*/ */
HireReplacedEmployee: string; HireReplacedEmployee: string;
// Проект/объект (справочник /Project) — справочное поле, на маршрут /**
// согласования НЕ влияет (в отличие от RecruitingSelection/TmcTransfer). * Проекты/объекты (справочник /Project), мультивыбор — хранится Note-колонкой
Project: string; * ProjectsJson. Старые колонки Project/ProjectId/ProjectCode тоже заполняются
ProjectId: string; // oid проекта ('' — если введён вручную без выбора из списка) * (см. projects.ts) — по ним заявки видны в списке SP, отчётах и прежнем коде.
ProjectCode: string; * На состав маршрута проект больше не влияет (шаг с условием по Project убран
* из маршрута в админке), но первый проект по-прежнему уходит в resolve.
*/
Projects: IProjectRef[];
// Связанная «Заявка на подбор» (по ней оформляют приём). Хранится тройкой // Связанная «Заявка на подбор» (по ней оформляют приём). Хранится тройкой
// полей, а не Lookup: списки партиционированы, см. selectionLookup.ts. // полей, а не Lookup: списки партиционированы, см. selectionLookup.ts.
SelectionRequestId: string; // Id заявки на подбор (→ rpid); '' — не выбрана SelectionRequestId: string; // Id заявки на подбор (→ rpid); '' — не выбрана
@@ -95,9 +98,7 @@ export const EMPTY_DATA: IFormData = {
Phone: '', Phone: '',
HireType: '', HireType: '',
HireReplacedEmployee: '', HireReplacedEmployee: '',
Project: '', Projects: [],
ProjectId: '',
ProjectCode: '',
SelectionRequestId: '', SelectionRequestId: '',
SelectionRequestListId: '', SelectionRequestListId: '',
SelectionRequestCode: '', SelectionRequestCode: '',
@@ -0,0 +1,51 @@
import { projectColumns, projectNames, projectsFromItem } from './projects';
const KES = { Id: 'g-kes', Name: 'КЭС Курчатов', Code: 'БК0000068' };
const OFFICE = { Id: 'g-office', Name: 'Офис TANSU', Code: '' };
describe('RecruitingHire projects', () => {
it('несколько проектов: JSON целиком, старые колонки — названия/коды через запятую, Id первого', () => {
expect(projectColumns([KES, OFFICE])).toEqual({
ProjectsJson: JSON.stringify([KES, OFFICE]),
Project: 'КЭС Курчатов, Офис TANSU',
ProjectId: 'g-kes',
ProjectCode: 'БК0000068'
});
});
it('без проектов все колонки очищаются', () => {
expect(projectColumns([])).toEqual({ ProjectsJson: null, Project: null, ProjectId: null, ProjectCode: null });
});
it('длинный список названий обрезается под 255 символов текстовой колонки', () => {
const long = Array.from({ length: 5 }, (_, i) => ({ Id: `g${i}`, Name: 'Строительство объекта '.repeat(4), Code: '' }));
const columns = projectColumns(long);
expect((columns.Project as string).length).toBe(255);
expect(columns.ProjectsJson).toBe(JSON.stringify(long));
});
it('чтение: ProjectsJson приоритетнее старых колонок', () => {
expect(projectsFromItem({ ProjectsJson: JSON.stringify([KES, OFFICE]), Project: 'старое', ProjectId: 'x' })).toEqual([
KES,
OFFICE
]);
});
it('чтение заявки до мультивыбора: один проект из старых колонок', () => {
expect(projectsFromItem({ Project: 'КЭС Курчатов', ProjectId: 'g-kes', ProjectCode: 'БК0000068' })).toEqual([KES]);
});
it('старая заявка с проектом, введённым вручную (без Id), — проектов нет', () => {
expect(projectsFromItem({ Project: 'что-то вручную', ProjectId: null })).toEqual([]);
});
it('битый JSON не роняет загрузку', () => {
expect(projectsFromItem({ ProjectsJson: '{не json', ProjectId: 'g-kes', Project: 'КЭС Курчатов' })).toEqual([
{ Id: 'g-kes', Name: 'КЭС Курчатов', Code: '' }
]);
});
it('названия одной строкой', () => {
expect(projectNames([KES, OFFICE])).toBe('КЭС Курчатов, Офис TANSU');
});
});
@@ -0,0 +1,71 @@
import { IProjectRef } from '../../shared/dictionary';
import { truncateSpText } from '../../shared/requestTitle';
/**
* Проекты заявки на приём: источник правды — массив Projects (колонка
* ProjectsJson). Старые одиночные колонки Project/ProjectId/ProjectCode
* выводятся из него при записи — их читают представления списка SP, отчёты и
* заявки, поданные до мультивыбора.
*/
/** Разделитель нескольких значений в старых текстовых колонках. */
const LEGACY_SEPARATOR = ', ';
/** Колонки ProjectsJson + старые одиночные колонки — для записи в список данных. */
export interface IProjectColumns {
ProjectsJson: string | null; // eslint-disable-line @rushstack/no-new-null -- null очищает колонку SP
Project: string | null; // eslint-disable-line @rushstack/no-new-null -- см. выше
/** Только первый проект: колонка задумана под один GUID, склейка GUID-ов бесполезна для поиска. */
ProjectId: string | null; // eslint-disable-line @rushstack/no-new-null -- см. выше
ProjectCode: string | null; // eslint-disable-line @rushstack/no-new-null -- см. выше
}
export function projectColumns(projects: IProjectRef[]): IProjectColumns {
if (projects.length === 0) {
return { ProjectsJson: null, Project: null, ProjectId: null, ProjectCode: null };
}
const codes = projects.map((p) => p.Code).filter(Boolean);
// Текстовые колонки — до 255 символов: несколько длинных названий иначе
// уронили бы сохранение. Полный список всегда есть в ProjectsJson.
return {
ProjectsJson: JSON.stringify(projects),
Project: truncateSpText(projectNames(projects)),
ProjectId: projects[0].Id,
ProjectCode: codes.length > 0 ? truncateSpText(codes.join(LEGACY_SEPARATOR)) : null
};
}
/** Названия проектов одной строкой — для заголовка заявки и старой колонки Project. */
export function projectNames(projects: IProjectRef[]): string {
return projects.map((p) => p.Name).join(LEGACY_SEPARATOR);
}
/**
* Проекты из элемента списка. Заявки, поданные до мультивыбора, ProjectsJson не
* имеют — тогда собираем один проект из старых колонок (если он был выбран из
* справочника, т.е. есть ProjectId).
*/
export function projectsFromItem(item: Record<string, unknown>): IProjectRef[] {
const fromJson = parseProjectsJson(item.ProjectsJson);
if (fromJson.length > 0) return fromJson;
const id = typeof item.ProjectId === 'string' ? item.ProjectId : '';
if (!id) return [];
return [
{
Id: id,
Name: typeof item.Project === 'string' ? item.Project : '',
Code: typeof item.ProjectCode === 'string' ? item.ProjectCode : ''
}
];
}
function parseProjectsJson(raw: unknown): IProjectRef[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as IProjectRef[]).filter((p) => !!p && !!p.Id) : [];
} catch {
return [];
}
}
@@ -12,6 +12,7 @@
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного сотрудника", "Вместо переведенного сотрудника", "Декрет"] }, { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного сотрудника", "Вместо переведенного сотрудника", "Декрет"] },
{ "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, { "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
{ "internalName": "ProjectsJson", "title": "Проекты (JSON)", "type": "Note" },
{ "internalName": "Project", "title": "Проект", "type": "Text" }, { "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" }, { "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" },
{ "internalName": "ProjectCode", "title": "Проект — код", "type": "Text" }, { "internalName": "ProjectCode", "title": "Проект — код", "type": "Text" },
@@ -1,6 +1,6 @@
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, ICompany } from '../../../shared/dictionary'; import { CompanyPickerMulti, ProjectPickerMulti, ICompany } from '../../../shared/dictionary';
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY } from '../model'; import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY } from '../model';
import { SelectionRequestField } from './SelectionRequestField'; import { SelectionRequestField } from './SelectionRequestField';
import { CompanySharesField } from './CompanySharesField'; import { CompanySharesField } from './CompanySharesField';
@@ -59,16 +59,12 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
shares={data.CompanyShares} shares={data.CompanyShares}
onSharesChange={(v) => set('CompanyShares', v)} onSharesChange={(v) => set('CompanyShares', v)}
/> />
<FormField label="1.3 Проект" htmlFor="Project" required full> <FormField label="1.3 Проект" htmlFor="Projects" required full>
<ProjectPicker <ProjectPickerMulti
context={context} context={context}
id="Project" id="Projects"
value={data.Project} value={data.Projects}
onChange={(name, id, project) => { onChange={(projects) => set('Projects', projects)}
set('Project', name);
set('ProjectId', id);
set('ProjectCode', project ? project.code : '');
}}
placeholder="Начните вводить название или код проекта…" placeholder="Начните вводить название или код проекта…"
/> />
</FormField> </FormField>
@@ -99,9 +99,13 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
set('Companies', selection.Companies); set('Companies', selection.Companies);
set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего
set('CompanyShares', []); set('CompanyShares', []);
set('Project', selection.ObjectProject); // В подборе проект один — он становится единственным выбранным проектом приёма.
set('ProjectId', selection.ObjectProjectId); set(
set('ProjectCode', selection.ObjectProjectCode); 'Projects',
selection.ObjectProjectId
? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }]
: []
);
set('Position', selection.Position); set('Position', selection.Position);
set('Department', selection.Department); set('Department', selection.Department);
set('WorkSchedule', selection.WorkSchedule); set('WorkSchedule', selection.WorkSchedule);
@@ -4,6 +4,7 @@ import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/f
import { buildRequestTitle } from '../../shared/requestTitle'; import { buildRequestTitle } from '../../shared/requestTitle';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking'; import { APPROVER_CHANGES_FIELD } from './changeTracking';
import { projectColumns, projectNames } from './projects';
// Числовые поля: пустая строка → null, иначе Number(). // Числовые поля: пустая строка → null, иначе Number().
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation', 'SelectionRequestId']; const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation', 'SelectionRequestId'];
@@ -17,9 +18,9 @@ export function hireApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId); return approvalServiceFor(FORM_ID, listId);
} }
/** Название заявки (Title): «ФИО кандидата — Должность — Проект». */ /** Название заявки (Title): «ФИО кандидата — Должность — Проекты». */
function buildTitle(data: IFormData): string { function buildTitle(data: IFormData): string {
return buildRequestTitle([data.HireCandidateName, data.Position, data.Project]); return buildRequestTitle([data.HireCandidateName, data.Position, projectNames(data.Projects)]);
} }
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */ /** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */
@@ -37,6 +38,11 @@ function buildFields(data: IFormData): Record<string, unknown> {
item.CompanySharesJson = data.CompanyShares.length > 0 ? JSON.stringify(data.CompanyShares) : null; item.CompanySharesJson = data.CompanyShares.length > 0 ? JSON.stringify(data.CompanyShares) : null;
return; return;
} }
if (key === 'Projects') {
// Мультивыбор проектов — Note ProjectsJson + старые одиночные колонки.
Object.assign(item, projectColumns(data.Projects));
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);
@@ -5,7 +5,7 @@ const DRAFT_READY: IFormData = {
...EMPTY_DATA, ...EMPTY_DATA,
HireCandidateName: 'Иванов Иван Иванович', HireCandidateName: 'Иванов Иван Иванович',
Position: 'Инженер', Position: 'Инженер',
ProjectId: '42' Projects: [{ Id: '42', Name: 'КЭС Курчатов', Code: 'БК0000068' }]
}; };
const labels = (issues: { label: string }[]): string[] => issues.map((i) => i.label); const labels = (issues: { label: string }[]): string[] => issues.map((i) => i.label);
@@ -32,9 +32,9 @@ describe('RecruitingHire validation', () => {
it('ключ поля совпадает с htmlFor у FormField — по нему подсветка и прокрутка', () => { it('ключ поля совпадает с htmlFor у FormField — по нему подсветка и прокрутка', () => {
expect(submitIssues(DRAFT_READY)[0]).toEqual({ field: 'Companies', label: '1.2 Организация', message: undefined }); expect(submitIssues(DRAFT_READY)[0]).toEqual({ field: 'Companies', label: '1.2 Организация', message: undefined });
expect(draftIssues(EMPTY_DATA)[0]).toEqual({ expect(draftIssues(EMPTY_DATA)[0]).toEqual({
field: 'Project', field: 'Projects',
label: '1.3 Проект', label: '1.3 Проект',
message: 'выберите вариант из списка' message: 'выберите хотя бы один проект из списка'
}); });
}); });
@@ -20,9 +20,9 @@ interface IRequiredRule extends IFieldIssue {
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект» * 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге. * (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
* *
* 1.3 «Проект» проверяется по ProjectId, а не по тексту: маршрут резолвится * 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve
* ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта approval-route * маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы
* вернёт 404 — заявка ушла бы на согласование с пустой матрицей. * на согласование с пустой матрицей. Ручной ввод мультивыбор не сохраняет.
*/ */
const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
{ field: 'Companies', label: '1.2 Организация', ok: (d) => d.Companies.length > 0 }, { field: 'Companies', label: '1.2 Организация', ok: (d) => d.Companies.length > 0 },
@@ -34,10 +34,10 @@ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
draft: true draft: true
}, },
{ {
field: 'Project', field: 'Projects',
label: '1.3 Проект', label: '1.3 Проект',
message: 'выберите вариант из списка', message: 'выберите хотя бы один проект из списка',
ok: (d) => !!d.ProjectId, ok: (d) => d.Projects.length > 0,
draft: true draft: true
}, },
{ field: 'Phone', label: '1.4 Телефон', ok: (d) => !!d.Phone.trim() }, { field: 'Phone', label: '1.4 Телефон', ok: (d) => !!d.Phone.trim() },
@@ -6,6 +6,9 @@ export { getProjects } from './projects.service';
export type { IProject } from './models/IProject'; export type { IProject } from './models/IProject';
export { ProjectPicker } from './ui/ProjectPicker'; export { ProjectPicker } from './ui/ProjectPicker';
export type { IProjectPickerProps } from './ui/ProjectPicker'; export type { IProjectPickerProps } from './ui/ProjectPicker';
export type { IProjectRef } from './models/IProjectRef';
export { ProjectPickerMulti } from './ui/ProjectPickerMulti';
export type { IProjectPickerMultiProps } from './ui/ProjectPickerMulti';
export { getCompanies } from './companies.service'; export { getCompanies } from './companies.service';
export type { ICompany } from './models/ICompany'; export type { ICompany } from './models/ICompany';
export { CompanyPickerMulti } from './ui/CompanyPickerMulti'; export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
@@ -0,0 +1,14 @@
/**
* Выбранный проект в мультивыборе (ProjectPickerMulti) — то, что хранится в
* заявке (Note-колонка *Json), а не вся запись справочника IProject.
* Ключи в PascalCase, как у ICompany: так одинаково читаются оба мультивыбора
* и журнал правок показывает их по Name (см. shared/fieldChanges).
*/
export interface IProjectRef {
/** GUID проекта (IProject.oid). */
Id: string;
/** Наименование проекта. */
Name: string;
/** Код проекта (IProject.code), '' — если в справочнике не задан. */
Code: string;
}
@@ -0,0 +1,114 @@
@import '../../../../../styles/design-tokens';
// Чипы и поле — как у CompanyPickerMulti, строка подсказки — как у ProjectPicker
// (название + мелкая строка «код · заказчик»).
.wrap {
position: relative;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
margin-bottom: $sp-1;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 6px 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
word-break: break-word;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.field {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.meta {
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,140 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ProjectPickerMulti.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { getProjects } from '../projects.service';
import { IProject } from '../models/IProject';
import { IProjectRef } from '../models/IProjectRef';
export interface IProjectPickerMultiProps {
context: WebPartContext;
id?: string;
/** Выбранные проекты (Id + Name + Code). */
value: IProjectRef[];
onChange: (projects: IProjectRef[]) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
function toRef(project: IProject): IProjectRef {
return { Id: project.oid, Name: project.name, Code: project.code || '' };
}
/**
* Мультивыбор проектов из справочника (GET /Project): ввод текста фильтрует
* список по названию и коду, выбранные показываются чипами. Ручной ввод без
* выбора из списка не сохраняется — в отличие от одиночного ProjectPicker.
*/
export const ProjectPickerMulti: React.FC<IProjectPickerMultiProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [projects, setProjects] = React.useState<IProject[]>([]);
const [query, setQuery] = React.useState<string>('');
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getProjects(context)
.then((list) => {
if (alive) setProjects(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const selectedIds = React.useMemo(() => new Set(value.map((p) => p.Id)), [value]);
const suggestions = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return projects
.filter(
(p) =>
!selectedIds.has(p.oid) &&
(p.name.toLowerCase().indexOf(q) !== -1 || (p.code && p.code.toLowerCase().indexOf(q) !== -1))
)
.slice(0, MAX_SUGGESTIONS);
}, [projects, query, selectedIds]);
const add = (project: IProject): void => {
onChange([...value, toRef(project)]);
setQuery('');
setOpen(false);
};
const remove = (projectId: string): void => {
onChange(value.filter((p) => p.Id !== projectId));
};
return (
<div className={styles.wrap}>
{value.length > 0 && (
<div className={styles.chips}>
{value.map((p) => (
<span key={p.Id} className={styles.chip}>
{p.Name}
{!effectiveReadOnly && (
<button
type="button"
className={styles.chipRemove}
aria-label={`Убрать ${p.Name}`}
title="Убрать"
onClick={() => remove(p.Id)}
>
×
</button>
)}
</span>
))}
</div>
)}
{!effectiveReadOnly && (
<div className={styles.field}>
<input
id={id}
className={styles.input}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{open && suggestions.length > 0 && (
<div className={styles.list}>
{suggestions.map((p) => (
<button
key={p.oid}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(p)}
>
{p.name}
<span className={styles.meta}>{[p.code, p.customerName].filter(Boolean).join(' · ')}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
};
@@ -1,11 +1,22 @@
/** Разделитель частей названия заявки. */ /** Разделитель частей названия заявки. */
const TITLE_SEPARATOR = ' — '; const TITLE_SEPARATOR = ' — ';
/** Предел однострочной текстовой колонки SharePoint (в т.ч. Title) — длиннее запись падает с ошибкой. */
export const SP_TEXT_MAX_LENGTH = 255;
const ELLIPSIS = '…';
/** Обрезать значение под однострочную текстовую колонку SharePoint (с многоточием). */
export function truncateSpText(value: string): string {
return value.length <= SP_TEXT_MAX_LENGTH ? value : `${value.slice(0, SP_TEXT_MAX_LENGTH - ELLIPSIS.length)}${ELLIPSIS}`;
}
/** /**
* Название заявки (системная колонка Title списка данных): ключевые поля через * Название заявки (системная колонка Title списка данных): ключевые поля через
* тире, пустые части пропускаются — «Инженер — Проект X». По нему заявку узнают в списке SP, в столбце «Заявка» списка согласования * тире, пустые части пропускаются — «Инженер — Проект X». По нему заявку узнают в списке SP, в столбце «Заявка» списка согласования
* и в колонке «Название» таблиц портала. * и в колонке «Название» таблиц портала. Обрезается до предела колонки: длинные
* названия проектов/объектов иначе роняли бы сохранение заявки.
*/ */
export function buildRequestTitle(parts: string[]): string { export function buildRequestTitle(parts: string[]): string {
return parts.map((p) => p.trim()).filter(Boolean).join(TITLE_SEPARATOR); return truncateSpText(parts.map((p) => p.trim()).filter(Boolean).join(TITLE_SEPARATOR));
} }