Compare commits
5 Commits
98be3980cb
...
cef8d51378
| Author | SHA1 | Date | |
|---|---|---|---|
| cef8d51378 | |||
| 0989168476 | |||
| 05b1163484 | |||
| 45e79d5401 | |||
| 8a6edf9d35 |
@@ -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" },
|
||||||
|
|||||||
+32
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user