feat: мультивыбор «Организации заявителя» в заявке на рабочее место
Tests / frontend-test (push) Successful in 1m16s

Поле было только для чтения и не позволяло поправить организации. Теперь
это CompanyPickerMulti, как в подборе: предзаполняется по заявителю и
редактируется, обязательное. Компании хранятся в WorkplaceCompaniesJson,
Organization пересобирается текстом для печати и старых заявок.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-25 17:38:50 +05:00
parent 0989168476
commit cef8d51378
8 changed files with 108 additions and 19 deletions
@@ -37,7 +37,7 @@ import {
IAttachmentInfo IAttachmentInfo
} from '../../shared/attachments/attachments.service'; } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { getEmployeeOrganizations, employeeCompanies } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { import {
@@ -127,7 +127,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
// Merge, а не replace: организация заявителя (ниже) резолвится отдельным // Merge, а не replace: организация заявителя (ниже) резолвится отдельным
// эффектом и могла успеть записаться раньше — не затираем её пустышкой // эффектом и могла успеть записаться раньше — не затираем её пустышкой
// из prefilled (в loadPrefillFromHire её нет). // из prefilled (в loadPrefillFromHire её нет).
setData((prev) => ({ ...prefilled, Organization: prev.Organization || prefilled.Organization })); setData((prev) => ({ ...prefilled, Companies: prev.Companies }));
} }
}) })
.catch((e) => { .catch((e) => {
@@ -149,8 +149,8 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
// Организации заявителя (Employee/ByEmail) — форма не о самом заявителе, а о // Организации заявителя (Employee/ByEmail) — форма не о самом заявителе, а о
// новом сотруднике (FullName — обычный текст, предзаполняется из «Заявки на // новом сотруднике (FullName — обычный текст, предзаполняется из «Заявки на
// прием», и его чаще всего ещё нет в системе кадров), поэтому организация // прием», и его чаще всего ещё нет в системе кадров), поэтому организация
// тянется по ТЕКУЩЕМУ пользователю. На редактировании уже сохранённое // тянется по ТЕКУЩЕМУ пользователю. Это только предложение: поле дальше
// значение не перезатираем. // редактируется вручную. На редактировании уже сохранённое значение не перезатираем.
useEffect(() => { useEffect(() => {
if (editId || !userEmail) { if (editId || !userEmail) {
return; return;
@@ -159,7 +159,10 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
getEmployeeOrganizations(context, userEmail) getEmployeeOrganizations(context, userEmail)
.then((records) => { .then((records) => {
if (alive) { if (alive) {
setData((prev) => ({ ...prev, Organization: prev.Organization || formatEmployeeOrganizations(records) })); setData((prev) => ({
...prev,
Companies: prev.Companies.length > 0 ? prev.Companies : employeeCompanies(records)
}));
} }
}) })
.catch(() => undefined); .catch(() => undefined);
@@ -213,9 +216,10 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login) (login) => approval.getJobTitle(login)
); );
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0; const isValid =
data.FullName.trim().length > 0 && data.Position.trim().length > 0 && data.Companies.length > 0;
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность.'; if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность, Организация заявителя.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined; return undefined;
@@ -35,7 +35,7 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, organizationText } from './model';
import { loadWorkplaceRequest } from './load'; import { loadWorkplaceRequest } from './load';
import { workplaceApprovalService, updateReviewFields } from './submit'; import { workplaceApprovalService, updateReviewFields } from './submit';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
@@ -218,7 +218,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
</div> </div>
{canEditFields && ( {canEditFields && (
@@ -235,7 +235,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
)} )}
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Организация рабочего места" title="Организация рабочего места"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
@@ -1,6 +1,21 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles'; import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { ICompany } from '../../shared/dictionary';
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model';
/**
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда
* организации есть только текстом в Organization) или битый JSON.
*/
function parseCompanies(raw: unknown): ICompany[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as ICompany[]) : [];
} catch {
return [];
}
}
const SCALAR_FIELDS: (keyof IFormData)[] = [ const SCALAR_FIELDS: (keyof IFormData)[] = [
'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'Project', 'ProjectId', 'WorkKind', 'Location', 'StartDate', 'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'Project', 'ProjectId', 'WorkKind', 'Location', 'StartDate',
@@ -26,7 +41,8 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'NeedsEquipment', COMPANIES_JSON_FIELD, 'RequestStatus', 'RequestUniqueCode')()
) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA }; const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>; const writable = data as unknown as Record<string, string>;
@@ -43,6 +59,7 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
}); });
data.NeedsEquipment = item.NeedsEquipment === true; data.NeedsEquipment = item.NeedsEquipment === true;
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]);
return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' }; return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' };
} }
@@ -1,5 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { ICompany } from '../../shared/dictionary';
/** /**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -7,6 +8,12 @@ import { RequestTypeId } from '../../models/types';
*/ */
export const FORM_ID: RequestTypeId = 'it-workplace-setup'; export const FORM_ID: RequestTypeId = 'it-workplace-setup';
/**
* Note-колонка с IFormData.Companies в JSON. Имя уникальное, а не CompaniesJson,
* как в подборе/приёме: одинаковые internalName в разных формах уже ломали колонки.
*/
export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson';
/** /**
* Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка * Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка
* «Организация рабочего места — данные». * «Организация рабочего места — данные».
@@ -33,10 +40,17 @@ export interface IFormData {
Location: string; Location: string;
StartDate: string; StartDate: string;
/** /**
* Организации заявителя (`\n`-joined «Компания — должность» на строку) — * Организации заявителя — мультивыбор из справочника (CompanyPickerMulti).
* подтягивается автоматически из Employee/ByEmail по e-mail заявителя (не * Предзаполняется из Employee/ByEmail по e-mail заявителя (не нового
* нового сотрудника — его ещё нет в системе кадров), read-only (см. * сотрудника — его ещё нет в системе кадров), дальше редактируется вручную.
* shared/ui/OrganizationField). * В SP хранится JSON-ом в колонке WorkplaceCompaniesJson (см. submit.ts).
*/
Companies: ICompany[];
/**
* Текст организаций (`\n`-joined, по строке на компанию) — колонка Organization.
* При сохранении пересобирается из Companies; читается для печати (OrgLetterhead)
* и для старых заявок, созданных до мультивыбора (у них Companies пуст).
* Брать значение — через organizationText(), а не напрямую.
*/ */
Organization: string; Organization: string;
// Оборудование и доступы (заполняются вручную) // Оборудование и доступы (заполняются вручную)
@@ -45,6 +59,15 @@ export interface IFormData {
AccessRights: string; AccessRights: string;
} }
/**
* Организации заявителя текстом, по строке на компанию — формат, который ждут
* PrintSplitButton/OrgLetterhead. Старые заявки (до мультивыбора) — сохранённый текст.
*/
export function organizationText(data: IFormData): string {
if (data.Companies.length === 0) return data.Organization;
return data.Companies.map((c) => c.Name).join('\n');
}
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void; export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
@@ -69,6 +92,7 @@ export const EMPTY_DATA: IFormData = {
WorkKind: '', WorkKind: '',
Location: '', Location: '',
StartDate: '', StartDate: '',
Companies: [],
Organization: '', Organization: '',
NeedsEquipment: false, NeedsEquipment: false,
Software: '', Software: '',
@@ -16,6 +16,7 @@
{ "internalName": "Location", "title": "Местоположение", "type": "Text" }, { "internalName": "Location", "title": "Местоположение", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, { "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
{ "internalName": "Organization", "title": "Организация заявителя", "type": "Note" }, { "internalName": "Organization", "title": "Организация заявителя", "type": "Note" },
{ "internalName": "WorkplaceCompaniesJson", "title": "Организации заявителя (JSON)", "type": "Note" },
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" }, { "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" }, { "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
@@ -0,0 +1,32 @@
import * as React from 'react';
import { FormField, OrganizationField, useFormReadOnly } from '../../../shared/ui';
import { CompanyPickerMulti } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
const ORGANIZATION_LABEL = 'Организация заявителя';
/**
* Организации заявителя: мультивыбор из справочника (как «1.5 Организация» в
* подборе). У старых заявок (до мультивыбора) компаний нет, только текст в
* Organization — в режиме чтения показываем его прежним полем.
*/
export const ApplicantOrganizationField: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex }) => {
const readOnly = useFormReadOnly();
const isLegacyText = data.Companies.length === 0 && data.Organization !== '';
if (readOnly && isLegacyText) {
return <OrganizationField value={data.Organization} label={ORGANIZATION_LABEL} printIndex={printOrgIndex} />;
}
return (
<FormField label={ORGANIZATION_LABEL} htmlFor="Companies" required full>
<CompanyPickerMulti
context={context}
id="Companies"
value={data.Companies}
onChange={(v) => set('Companies', v)}
placeholder="Начните вводить название организации…"
/>
</FormField>
);
};
@@ -1,7 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, OrganizationField } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary'; import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
import { ApplicantOrganizationField } from './ApplicantOrganizationField';
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex }) => ( export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex }) => (
<CollapseSection title="Сотрудник" icon="people"> <CollapseSection title="Сотрудник" icon="people">
@@ -49,6 +50,6 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, p
<FormField label="Дата приёма" htmlFor="StartDate"> <FormField label="Дата приёма" htmlFor="StartDate">
<TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} /> <TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} />
</FormField> </FormField>
<OrganizationField value={data.Organization} label="Организация заявителя" printIndex={printOrgIndex} /> <ApplicantOrganizationField data={data} set={set} context={context} printOrgIndex={printOrgIndex} />
</CollapseSection> </CollapseSection>
); );
@@ -1,6 +1,6 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
import { FORM_ID, IFormData } from './model'; import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model';
/** /**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
@@ -40,6 +40,16 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status }; const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => { (Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
if (key === 'Companies') {
item[COMPANIES_JSON_FIELD] = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
return;
}
if (key === 'Organization') {
// Текстовая копия выбранных компаний — для печати и списка в SP (см. organizationText).
const text = organizationText(data);
item.Organization = text === '' ? null : text;
return;
}
const value = data[key]; const value = data[key];
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
item[key] = value; item[key] = value;