diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index d65ed13..52b249f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -154,11 +154,11 @@ const RecruitingHireForm: React.FC = ({ // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeId = requestTypeIdState.id; - // Маршрут для этого типа заявки зависит от проекта (ByProject) — без projectId - // бэкенд отвечает resolve: 404 «…и признака «по проекту» не найден» (поле - // Project изначально задумывалось справочным, но выяснилось, что маршрут - // всё-таки требует projectId, как в RecruitingSelection/TmcTransfer). - const projectId = data.ProjectId || null; + // Без projectId бэкенд отвечал resolve: 404 «…и признака «по проекту» не + // найден». Сейчас в маршруте условий по проекту нет, но проверка projectId на + // бэкенде не подтверждена снятой — поэтому передаём ПЕРВЫЙ выбранный проект + // (поле — мультивыбор) и ждём его выбора, как раньше. + const projectId = data.Projects.length > 0 ? data.Projects[0].Id : null; const matrixReady = !!editId || (!!projectId && !!requestTypeId); // Несколько шагов маршрута условны по компании (см. панель «Маршруты»: // «CompaniesJson = TOO QAZGEOMAX», «CompaniesJson = TOO CT Global», шаг с @@ -166,15 +166,16 @@ const RecruitingHireForm: React.FC = ({ // fieldValues, value — имена выбранных компаний ЧЕРЕЗ ПРОБЕЛ (не JSON, не // массив — ResolveFieldValueDto на бэкенде принимает только строку, массив // даёт 400; сериализованный JSON не совпадает с условием при сравнении). - // Плюс шаг «Роль: Планировщик» условен по названию проекта («Project не - // содержит офис») — сюда идёт ТЕКСТ (data.Project), а не projectId (GUID), - // который уже передаётся отдельным параметром resolve. + // Условие по названию проекта («Project не содержит офис») из маршрута убрано, + // но Project передаём по-прежнему — на случай, если такое условие вернут в + // админке: ТЕКСТ названий всех проектов через пробел (как у компаний), а не + // GUID — тот уходит отдельным параметром resolve. const companiesFieldValue = data.Companies.map((c) => c.Name).sort().join(' '); + const projectsFieldValue = data.Projects.map((p) => p.Name).join(' '); const fieldValues = useMemo(() => { const companies = companiesFieldValue ? [{ fieldName: 'CompaniesJson', value: companiesFieldValue }] : []; - return data.Project ? [...companies, { fieldName: 'Project', value: data.Project }] : companies; - // eslint-disable-next-line react-hooks/exhaustive-deps -- companiesFieldValue — стабильный слепок data.Companies - }, [companiesFieldValue, data.Project]); + return projectsFieldValue ? [...companies, { fieldName: 'Project', value: projectsFieldValue }] : companies; + }, [companiesFieldValue, projectsFieldValue]); const loadMatrix = useCallback( () => editId diff --git a/src/webparts/requestPortal/forms/RecruitingHire/changeTracking.ts b/src/webparts/requestPortal/forms/RecruitingHire/changeTracking.ts index 90d0272..ec63e24 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/changeTracking.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/changeTracking.ts @@ -12,12 +12,12 @@ function companyName(data: IFormData, companyId: string): string { /** * Что пишем в журнал правок согласующего (shared/fieldChanges). * - * Без своего поля на форме (не пишем): Id/код проекта — меняются вместе с - * «Проект»; связь с подбором — вместе с её кодом (1.1); HireOnArrival — флажок - * внутри поля 2.2, его правку видно по самой дате. + * Без своего поля на форме (не пишем): связь с подбором — меняется вместе с её + * кодом (1.1); HireOnArrival — флажок внутри поля 2.2, его правку видно по + * самой дате. Проекты (Projects) пишутся как есть — списком названий. */ export const HIRE_CHANGE_TRACKING: IChangeTrackingConfig = { - ignore: ['ProjectId', 'ProjectCode', 'SelectionRequestId', 'SelectionRequestListId', 'HireOnArrival'], + ignore: ['SelectionRequestId', 'SelectionRequestListId', 'HireOnArrival'], formatters: { MainCompanyId: (value, data) => (value ? companyName(data, String(value)) : EMPTY_VALUE), CompanyShares: (value, data) => { diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index 954351f..6928ad4 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -5,6 +5,7 @@ import { spDateOnlyToIso } from '../../shared/dateUtils'; import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare, LEGACY_HIRE_TYPES } from './model'; import { APPROVER_CHANGES_FIELD } from './changeTracking'; +import { projectsFromItem } from './projects'; /** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */ function parseJsonArray(raw: unknown): T[] { @@ -20,7 +21,7 @@ function parseJsonArray(raw: unknown): T[] { // Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке. const SCALAR_FIELDS: (keyof IFormData)[] = [ 'MainCompanyId', - 'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode', + 'Phone', 'HireType', 'HireReplacedEmployee', 'HireCandidateName', 'HireHireDate', 'Position', 'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', @@ -54,6 +55,11 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise< ...SCALAR_FIELDS, 'CompaniesJson', 'CompanySharesJson', + // Старые одиночные колонки — для заявок, поданных до мультивыбора (см. projects.ts). + 'ProjectsJson', + 'Project', + 'ProjectId', + 'ProjectCode', 'RecruitingAgency', 'HireOnArrival', APPROVER_CHANGES_FIELD, @@ -68,6 +74,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise< (data as unknown as Record).Companies = parseJsonArray(item.CompaniesJson); // % ставки по компаниям (совмещение) — аналогично, Note-колонка CompanySharesJson. (data as unknown as Record).CompanyShares = parseJsonArray(item.CompanySharesJson); + data.Projects = projectsFromItem(item); // Флаг агентства — булево поле (Yes/No), не строка. (data as unknown as Record).RecruitingAgency = !!item.RecruitingAgency; (data as unknown as Record).HireOnArrival = !!item.HireOnArrival; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/model.ts b/src/webparts/requestPortal/forms/RecruitingHire/model.ts index 94606a2..0ebaba9 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/model.ts @@ -1,6 +1,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; import { ISelectOption } from '../../shared/ui'; -import { ICompany } from '../../shared/dictionary'; +import { ICompany, IProjectRef } from '../../shared/dictionary'; import { RequestTypeId } from '../../models/types'; /** @@ -38,11 +38,14 @@ export interface IFormData { * там имя осталось прежним. */ HireReplacedEmployee: string; - // Проект/объект (справочник /Project) — справочное поле, на маршрут - // согласования НЕ влияет (в отличие от RecruitingSelection/TmcTransfer). - Project: string; - ProjectId: string; // oid проекта ('' — если введён вручную без выбора из списка) - ProjectCode: string; + /** + * Проекты/объекты (справочник /Project), мультивыбор — хранится Note-колонкой + * ProjectsJson. Старые колонки Project/ProjectId/ProjectCode тоже заполняются + * (см. projects.ts) — по ним заявки видны в списке SP, отчётах и прежнем коде. + * На состав маршрута проект больше не влияет (шаг с условием по Project убран + * из маршрута в админке), но первый проект по-прежнему уходит в resolve. + */ + Projects: IProjectRef[]; // Связанная «Заявка на подбор» (по ней оформляют приём). Хранится тройкой // полей, а не Lookup: списки партиционированы, см. selectionLookup.ts. SelectionRequestId: string; // Id заявки на подбор (→ rpid); '' — не выбрана @@ -95,9 +98,7 @@ export const EMPTY_DATA: IFormData = { Phone: '', HireType: '', HireReplacedEmployee: '', - Project: '', - ProjectId: '', - ProjectCode: '', + Projects: [], SelectionRequestId: '', SelectionRequestListId: '', SelectionRequestCode: '', diff --git a/src/webparts/requestPortal/forms/RecruitingHire/projects.test.ts b/src/webparts/requestPortal/forms/RecruitingHire/projects.test.ts new file mode 100644 index 0000000..2a93cc4 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/projects.test.ts @@ -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'); + }); +}); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/projects.ts b/src/webparts/requestPortal/forms/RecruitingHire/projects.ts new file mode 100644 index 0000000..13a26c1 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/projects.ts @@ -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): 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 []; + } +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json index 830f713..c73ca7f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json @@ -12,6 +12,7 @@ { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного сотрудника", "Вместо переведенного сотрудника", "Декрет"] }, { "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, + { "internalName": "ProjectsJson", "title": "Проекты (JSON)", "type": "Note" }, { "internalName": "Project", "title": "Проект", "type": "Text" }, { "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" }, { "internalName": "ProjectCode", "title": "Проект — код", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx index 297cbc5..47e0558 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; 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 { SelectionRequestField } from './SelectionRequestField'; import { CompanySharesField } from './CompanySharesField'; @@ -59,16 +59,12 @@ export const ApplicationSection: React.FC = ({ data, set, context shares={data.CompanyShares} onSharesChange={(v) => set('CompanyShares', v)} /> - - + { - set('Project', name); - set('ProjectId', id); - set('ProjectCode', project ? project.code : ''); - }} + id="Projects" + value={data.Projects} + onChange={(projects) => set('Projects', projects)} placeholder="Начните вводить название или код проекта…" /> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx index e06a5a3..84106be 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx @@ -99,9 +99,13 @@ export const SelectionRequestField: React.FC = ({ d set('Companies', selection.Companies); set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего set('CompanyShares', []); - set('Project', selection.ObjectProject); - set('ProjectId', selection.ObjectProjectId); - set('ProjectCode', selection.ObjectProjectCode); + // В подборе проект один — он становится единственным выбранным проектом приёма. + set( + 'Projects', + selection.ObjectProjectId + ? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }] + : [] + ); set('Position', selection.Position); set('Department', selection.Department); set('WorkSchedule', selection.WorkSchedule); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index c8e1508..f5af743 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -4,6 +4,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 { projectColumns, projectNames } from './projects'; // Числовые поля: пустая строка → null, иначе Number(). 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); } -/** Название заявки (Title): «ФИО кандидата — Должность — Проект». */ +/** Название заявки (Title): «ФИО кандидата — Должность — Проекты». */ 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 { item.CompanySharesJson = data.CompanyShares.length > 0 ? JSON.stringify(data.CompanyShares) : null; return; } + if (key === 'Projects') { + // Мультивыбор проектов — Note ProjectsJson + старые одиночные колонки. + Object.assign(item, projectColumns(data.Projects)); + return; + } const value = data[key]; if (NUMBER_FIELDS.indexOf(key) !== -1) { item[key] = value === '' ? null : Number(value); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/validation.test.ts b/src/webparts/requestPortal/forms/RecruitingHire/validation.test.ts index 7e8c31c..3187f72 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/validation.test.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/validation.test.ts @@ -5,7 +5,7 @@ const DRAFT_READY: IFormData = { ...EMPTY_DATA, HireCandidateName: 'Иванов Иван Иванович', Position: 'Инженер', - ProjectId: '42' + Projects: [{ Id: '42', Name: 'КЭС Курчатов', Code: 'БК0000068' }] }; const labels = (issues: { label: string }[]): string[] => issues.map((i) => i.label); @@ -32,9 +32,9 @@ describe('RecruitingHire validation', () => { it('ключ поля совпадает с htmlFor у FormField — по нему подсветка и прокрутка', () => { expect(submitIssues(DRAFT_READY)[0]).toEqual({ field: 'Companies', label: '1.2 Организация', message: undefined }); expect(draftIssues(EMPTY_DATA)[0]).toEqual({ - field: 'Project', + field: 'Projects', label: '1.3 Проект', - message: 'выберите вариант из списка' + message: 'выберите хотя бы один проект из списка' }); }); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/validation.ts b/src/webparts/requestPortal/forms/RecruitingHire/validation.ts index 0b54f51..fe708a6 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/validation.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/validation.ts @@ -20,9 +20,9 @@ interface IRequiredRule extends IFieldIssue { * 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект» * (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге. * - * 1.3 «Проект» проверяется по ProjectId, а не по тексту: маршрут резолвится - * ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта approval-route - * вернёт 404 — заявка ушла бы на согласование с пустой матрицей. + * 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve + * маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы + * на согласование с пустой матрицей. Ручной ввод мультивыбор не сохраняет. */ const REQUIRED_RULES: ReadonlyArray = [ { field: 'Companies', label: '1.2 Организация', ok: (d) => d.Companies.length > 0 }, @@ -34,10 +34,10 @@ const REQUIRED_RULES: ReadonlyArray = [ draft: true }, { - field: 'Project', + field: 'Projects', label: '1.3 Проект', - message: 'выберите вариант из списка', - ok: (d) => !!d.ProjectId, + message: 'выберите хотя бы один проект из списка', + ok: (d) => d.Projects.length > 0, draft: true }, { field: 'Phone', label: '1.4 Телефон', ok: (d) => !!d.Phone.trim() }, diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index 727740e..08e96fe 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -6,6 +6,9 @@ export { getProjects } from './projects.service'; export type { IProject } from './models/IProject'; export { ProjectPicker } 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 type { ICompany } from './models/ICompany'; export { CompanyPickerMulti } from './ui/CompanyPickerMulti'; diff --git a/src/webparts/requestPortal/shared/dictionary/models/IProjectRef.ts b/src/webparts/requestPortal/shared/dictionary/models/IProjectRef.ts new file mode 100644 index 0000000..de8d6b6 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/models/IProjectRef.ts @@ -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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/ui/ProjectPickerMulti.module.scss b/src/webparts/requestPortal/shared/dictionary/ui/ProjectPickerMulti.module.scss new file mode 100644 index 0000000..a1ae2dd --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/ProjectPickerMulti.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/ui/ProjectPickerMulti.tsx b/src/webparts/requestPortal/shared/dictionary/ui/ProjectPickerMulti.tsx new file mode 100644 index 0000000..6100821 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/ProjectPickerMulti.tsx @@ -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 = ({ + context, + id, + value, + onChange, + placeholder, + readOnly +}) => { + const contextReadOnly = useFormReadOnly(); + const effectiveReadOnly = readOnly ?? contextReadOnly; + + const [projects, setProjects] = React.useState([]); + const [query, setQuery] = React.useState(''); + const [open, setOpen] = React.useState(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 ( +
+ {value.length > 0 && ( +
+ {value.map((p) => ( + + {p.Name} + {!effectiveReadOnly && ( + + )} + + ))} +
+ )} + + {!effectiveReadOnly && ( +
+ { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + /> + {open && suggestions.length > 0 && ( +
+ {suggestions.map((p) => ( + + ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/webparts/requestPortal/shared/requestTitle.ts b/src/webparts/requestPortal/shared/requestTitle.ts index db38805..e8374d9 100644 --- a/src/webparts/requestPortal/shared/requestTitle.ts +++ b/src/webparts/requestPortal/shared/requestTitle.ts @@ -1,11 +1,22 @@ /** Разделитель частей названия заявки. */ 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 списка данных): ключевые поля через * тире, пустые части пропускаются — «Инженер — Проект X». По нему заявку узнают в списке SP, в столбце «Заявка» списка согласования - * и в колонке «Название» таблиц портала. + * и в колонке «Название» таблиц портала. Обрезается до предела колонки: длинные + * названия проектов/объектов иначе роняли бы сохранение заявки. */ 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)); }