5 Commits

Author SHA1 Message Date
Тимур Городецкий cef8d51378 feat: мультивыбор «Организации заявителя» в заявке на рабочее место
Tests / frontend-test (push) Successful in 1m16s
Поле было только для чтения и не позволяло поправить организации. Теперь
это CompanyPickerMulti, как в подборе: предзаполняется по заявителю и
редактируется, обязательное. Компании хранятся в WorkplaceCompaniesJson,
Organization пересобирается текстом для печати и старых заявок.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:38:50 +05:00
Тимур Городецкий 0989168476 refactor: преобразование организаций сотрудника в компании вынесено в shared/dictionary
employeeCompanies() нужен теперь не только подбору, но и заявке на рабочее
место — одна реализация вместо копий в каждой форме.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:38:50 +05:00
Тимур Городецкий 05b1163484 Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal 2026-09-25 17:09:03 +05:00
Тимур Городецкий 45e79d5401 fix: в приёме можно выбрать подбор в статусе «Выполнение» и «Закрыта»
После согласования подбор сразу уходит в «Выполнение», поэтому фильтр
только по «Согласовано» почти всегда давал пустой список. Теперь берутся
все утверждённые статусы, как в isApprovedStatus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:08:29 +05:00
Тимур Городецкий 8a6edf9d35 feat: ручной ввод базы 1С при выборе варианта «Другое»
Если нужной базы нет в списке, заявитель отмечает «Другое» и вписывает
название в обязательное поле. Текст хранится в отдельной колонке DB1COther,
показывается в просмотре и попадает в карточку Planner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:08:29 +05:00
20 changed files with 180 additions and 44 deletions
@@ -28,7 +28,7 @@
заявки кнопка «Организация рабочего места на основе этой» открывает форму заявки кнопка «Организация рабочего места на основе этой» открывает форму
it-workplace-setup с префиллом (rpsource = id этой заявки). it-workplace-setup с префиллом (rpsource = id этой заявки).
- **Связь с «Заявкой на подбор» (SelectionRequest\*)** — необязательное поле в - **Связь с «Заявкой на подбор» (SelectionRequest\*)** — необязательное поле в
секции «Заявка»: поиск-выбор согласованной заявки на подбор (`SelectionRequestField` секции «Заявка»: поиск-выбор утверждённой (Согласовано / Выполнение / Закрыта) заявки на подбор (`SelectionRequestField`
+ `selectionLookup.ts`, только статус «Согласовано», ищет по всем партициям серии). + `selectionLookup.ts`, только статус «Согласовано», ищет по всем партициям серии).
Связь хранится тройкой полей `SelectionRequestId` / `SelectionRequestListId` / Связь хранится тройкой полей `SelectionRequestId` / `SelectionRequestListId` /
`SelectionRequestCode`, а НЕ нативным Lookup — списки партиционированы. В форме `SelectionRequestCode`, а НЕ нативным Lookup — списки партиционированы. В форме
@@ -210,7 +210,7 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
)) ))
) : ( ) : (
<div className={styles.note}> <div className={styles.note}>
{loaded ? 'Нет согласованных заявок на подбор.' : 'Загрузка…'} {loaded ? 'Нет утверждённых заявок на подбор.' : 'Загрузка…'}
</div> </div>
)} )}
</div> </div>
@@ -28,8 +28,11 @@ export interface ISelectionRequestOption {
title: string; title: string;
} }
// Только согласованные заявки на подбор: приём оформляют по уже утверждённому подбору. // Только утверждённые заявки на подбор: приём оформляют по уже утверждённому подбору.
const APPROVED_FILTER = `RequestStatus eq '${REQUEST_STATUS.approved}'`; // После согласования подбор уходит исполнителю («Выполнение»), затем «Закрыта» —
// это тот же утверждённый подбор, поэтому берём все три статуса (как isApprovedStatus).
const APPROVED_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed];
const APPROVED_FILTER = APPROVED_STATUSES.map((s) => `RequestStatus eq '${s}'`).join(' or ');
// Предохранитель: партиция всё равно ограничена порогом элементов, но берём // Предохранитель: партиция всё равно ограничена порогом элементов, но берём
// разумный потолок, чтобы одиночный запрос не тянул весь список целиком. // разумный потолок, чтобы одиночный запрос не тянул весь список целиком.
@@ -64,7 +67,7 @@ async function queryPartition(list: IListLike, listId: string): Promise<ISelecti
} }
/** /**
* Все согласованные заявки на подбор из всех партиций серии (архив + текущая). * Все утверждённые (Согласовано / Выполнение / Закрыта) заявки на подбор из всех партиций серии (архив + текущая).
* *
* Реестр партиций знает серию → читаем каждую партицию по её listId (url), * Реестр партиций знает серию → читаем каждую партицию по её listId (url),
* заодно получая правильный rplist для ссылки. Реестр недоступен (approval-route * заодно получая правильный rplist для ссылки. Реестр недоступен (approval-route
@@ -42,7 +42,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, ICompany, IEmployeeOrganization } 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 {
@@ -120,16 +120,11 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
}; };
}, [editId, editListId, notifyCode, setSubmitError]); }, [editId, editListId, notifyCode, setSubmitError]);
// Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение
// «1.5 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail
// совпадает с Id из Dictionary/Companies (тот же источник) — доп. запрос не нужен.
// Поле остаётся редактируемым: значения только ПРЕДЛАГАЮТСЯ, как и DepartmentName.
const toCompanies = (records: IEmployeeOrganization[]): ICompany[] =>
records.filter((r) => r.companyId && r.company).map((r) => ({ Id: r.companyId, Name: r.company }));
// Новая заявка — по умолчанию ответственный: сам подающий, подразделение и // Новая заявка — по умолчанию ответственный: сам подающий, подразделение и
// организация — из Employee/ByEmail (Section/company), как в остальных формах // организация — из Employee/ByEmail (Section/company), как в остальных формах
// с авто-«Организацией» (см. shared/dictionary/employeeOrganizations.service.ts). // с авто-«Организацией» (см. shared/dictionary/employeeOrganizations.service.ts).
// «1.5 Организация» остаётся редактируемой: значения только ПРЕДЛАГАЮТСЯ,
// как и DepartmentName.
useEffect(() => { useEffect(() => {
if (editId) { if (editId) {
return; return;
@@ -143,7 +138,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
setData((prev) => ({ setData((prev) => ({
...prev, ...prev,
DepartmentName: prev.DepartmentName || records[0]?.section || prev.DepartmentName, DepartmentName: prev.DepartmentName || records[0]?.section || prev.DepartmentName,
Companies: prev.Companies.length > 0 ? prev.Companies : toCompanies(records) Companies: prev.Companies.length > 0 ? prev.Companies : employeeCompanies(records)
})); }));
} }
}) })
@@ -166,7 +161,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
setData((prev) => ({ setData((prev) => ({
...prev, ...prev,
DepartmentName: records[0]?.section || prev.DepartmentName, DepartmentName: records[0]?.section || prev.DepartmentName,
Companies: toCompanies(records) Companies: employeeCompanies(records)
})) }))
) )
.catch(() => undefined); .catch(() => undefined);
@@ -45,7 +45,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, hasOtherDatabase } from './model';
import { saveTicketRequest, ticket1cApprovalService } from './submit'; import { saveTicketRequest, ticket1cApprovalService } from './submit';
import { loadTicketRequest } from './load'; import { loadTicketRequest } from './load';
import { getApplicantProfile } from './services/profile.service'; import { getApplicantProfile } from './services/profile.service';
@@ -222,17 +222,20 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
); );
const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]); const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]);
const isValid = !!( const isOtherDatabaseMissing = hasOtherDatabase(data) && !data.DB1COther.trim();
const areRequiredFilled = !!(
data.TaskName.trim() && data.TaskName.trim() &&
data.WorkType && data.WorkType &&
data.Organization && data.Organization &&
data.DB1C.length > 0 && data.DB1C.length > 0 &&
data.Question.trim() data.Question.trim()
); );
const isValid = areRequiredFilled && !isOtherDatabaseMissing;
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!isValid) { if (!areRequiredFilled) {
return 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.'; return 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.';
} }
if (isOtherDatabaseMissing) return 'Выбрана база 1С «Другое» — укажите её название.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined; return undefined;
@@ -34,7 +34,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, hasOtherDatabase } from './model';
import { loadTicketRequest } from './load'; import { loadTicketRequest } from './load';
import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit'; import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit';
import { PlannerTicketService } from './services/plannerTicket.service'; import { PlannerTicketService } from './services/plannerTicket.service';
@@ -69,6 +69,7 @@ function buildPlannerNotes(
lines.push(`Отдел: ${data.Department}`); lines.push(`Отдел: ${data.Department}`);
lines.push(`Должность: ${data.Position}`); lines.push(`Должность: ${data.Position}`);
lines.push(`База 1С: ${data.DB1C.join(', ')}`); lines.push(`База 1С: ${data.DB1C.join(', ')}`);
if (hasOtherDatabase(data)) lines.push(`Другая база 1С: ${data.DB1COther}`);
lines.push(`Виды работ: ${data.WorkType}`); lines.push(`Виды работ: ${data.WorkType}`);
lines.push(''); lines.push('');
lines.push('Вопрос:'); lines.push('Вопрос:');
@@ -5,7 +5,7 @@ import { FORM_ID, IFormData, EMPTY_DATA } from './model';
// TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName). // TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName).
// DB1C — отдельно, это множественный выбор (см. toStringArray). // DB1C — отдельно, это множественный выбор (см. toStringArray).
const SCALAR_FIELDS: (keyof IFormData)[] = [ const SCALAR_FIELDS: (keyof IFormData)[] = [
'WorkType', 'Organization', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question', 'WorkType', 'Organization', 'DB1COther', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question',
]; ];
/** /**
@@ -18,6 +18,8 @@ export interface IFormData {
Organization: string; Organization: string;
/** Базы 1С — множественный выбор (колонка MultiChoice), минимум одна. */ /** Базы 1С — множественный выбор (колонка MultiChoice), минимум одна. */
DB1C: string[]; DB1C: string[];
/** Название базы вручную — обязательно, только если в DB1C отмечено «Другое». */
DB1COther: string;
FIO: string; FIO: string;
Email: string; Email: string;
Phone: string; Phone: string;
@@ -32,6 +34,16 @@ export interface IFormData {
Question: string; Question: string;
} }
/**
* Вариант колонки «База 1С», при выборе которого заявитель вписывает базу сам
* (поле DB1COther). Должен дословно совпадать с вариантом в колонке SP.
*/
export const DB1C_OTHER_OPTION = 'Другое';
export function hasOtherDatabase(data: IFormData): boolean {
return data.DB1C.indexOf(DB1C_OTHER_OPTION) !== -1;
}
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
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;
@@ -47,6 +59,7 @@ export const EMPTY_DATA: IFormData = {
WorkType: '', WorkType: '',
Organization: '', Organization: '',
DB1C: [], DB1C: [],
DB1COther: '',
FIO: '', FIO: '',
Email: '', Email: '',
Phone: '', Phone: '',
@@ -39,8 +39,10 @@
"1С: Бухгалтерия ГеоТрек", "1С: Бухгалтерия ГеоТрек",
"1С: Бухгалтерия Монтажник-Т", "1С: Бухгалтерия Монтажник-Т",
"1С: Бухгалтерия ОСН Беркут", "1С: Бухгалтерия ОСН Беркут",
"1С: Бухгалтерия CT Global" "1С: Бухгалтерия CT Global",
"Другое"
] }, ] },
{ "internalName": "DB1COther", "title": "База 1С (другое)", "type": "Text" },
{ "internalName": "FIO", "title": "Ф.И.О.", "type": "Text" }, { "internalName": "FIO", "title": "Ф.И.О.", "type": "Text" },
{ "internalName": "Email", "title": "Email", "type": "Text" }, { "internalName": "Email", "title": "Email", "type": "Text" },
@@ -2,7 +2,7 @@ import * as React from 'react';
import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui'; import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices'; import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices';
import { dataListTitle } from '../../../shared/listTitles'; import { dataListTitle } from '../../../shared/listTitles';
import { FORM_ID, ISectionProps } from '../model'; import { DB1C_OTHER_OPTION, FORM_ID, ISectionProps, hasOtherDatabase } from '../model';
import { import {
QUESTION_MAX, QUESTION_MAX,
CHOICES_LOADING_PLACEHOLDER, CHOICES_LOADING_PLACEHOLDER,
@@ -55,14 +55,30 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
placeholder={choicesPlaceholder(organizations, '— выберите —')} placeholder={choicesPlaceholder(organizations, '— выберите —')}
/> />
</FormField> </FormField>
<FormField label="База 1С" fieldKey="DB1C" required> {/* На всю ширину: чипы выбранных баз растят поле по высоте, и соседнее
поле в той же строке сетки съезжало бы относительно него. */}
<FormField label="База 1С" fieldKey="DB1C" required full>
<MultiChoiceField <MultiChoiceField
value={data.DB1C} value={data.DB1C}
onChange={(v) => set('DB1C', v)} onChange={(v) => {
set('DB1C', v);
// Сняли «Другое» — вписанное название больше не относится к заявке.
if (v.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', '');
}}
options={databases.options} options={databases.options}
placeholder={choicesPlaceholder(databases, 'Выберите одну или несколько баз')} placeholder={choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
/> />
</FormField> </FormField>
{hasOtherDatabase(data) && (
<FormField label="Другая база 1С" htmlFor="DB1COther" required full>
<TextInput
id="DB1COther"
value={data.DB1COther}
onChange={(v) => set('DB1COther', v)}
placeholder="Укажите название базы"
/>
</FormField>
)}
<FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full> <FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full>
<TextArea <TextArea
id="Question" id="Question"
@@ -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;
@@ -2,6 +2,7 @@ import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config'; import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
import { IEmployeeOrganization } from './models/IEmployeeOrganization'; import { IEmployeeOrganization } from './models/IEmployeeOrganization';
import { ICompany } from './models/ICompany';
// GET /Employee/ByEmail?Email=... — организации сотрудника (см. IEmployeeOrganization: // GET /Employee/ByEmail?Email=... — организации сотрудника (см. IEmployeeOrganization:
// один email может вернуть несколько записей). 404 (email не найден в системе // один email может вернуть несколько записей). 404 (email не найден в системе
@@ -57,3 +58,12 @@ export function getEmployeeOrganizations(context: WebPartContext, email: string)
export function formatEmployeeOrganizations(records: IEmployeeOrganization[]): string { export function formatEmployeeOrganizations(records: IEmployeeOrganization[]): string {
return records.map((r) => (r.position ? `${r.company} — ${r.position}` : r.company)).join('\n'); return records.map((r) => (r.position ? `${r.company} — ${r.position}` : r.company)).join('\n');
} }
/**
* Организации сотрудника → значение для CompanyPickerMulti. companyId из
* Employee/ByEmail совпадает с Id в Dictionary/Companies (тот же источник),
* поэтому доп. запрос в справочник не нужен. Записи без компании пропускаем.
*/
export function employeeCompanies(records: IEmployeeOrganization[]): ICompany[] {
return records.filter((r) => r.companyId && r.company).map((r) => ({ Id: r.companyId, Name: r.company }));
}
@@ -10,7 +10,11 @@ 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';
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti'; export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
export { getEmployeeOrganizations, formatEmployeeOrganizations } from './employeeOrganizations.service'; export {
getEmployeeOrganizations,
formatEmployeeOrganizations,
employeeCompanies
} from './employeeOrganizations.service';
export type { IEmployeeOrganization } from './models/IEmployeeOrganization'; export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
export { getNomenclature } from './nomenclature.service'; export { getNomenclature } from './nomenclature.service';
export type { INomenclatureRow } from './models/INomenclatureRow'; export type { INomenclatureRow } from './models/INomenclatureRow';