feat: feat: брендированная печать, поле «Подразделение» и подача ТМЦ за другого

- Печать доступна только после согласования (Отдел рекрутинга); выбор
  организации и логотип-шапка для форм с несколькими юрлицами
- «СЗ на перемещение ТМЦ»: заявителя можно выбрать (подача за другого),
  маршрут резолвится на выбранного
- «Реферальная рекомендация»: организация вакансии (мультивыбор) и
  подразделение/проект вакансии
- Поле «Подразделение» (справочник) — Vacation, UnpaidLeave, Clearance,
  WorkplaceSetup, RecruitingHire, RecruitingSelection, TmcTransfer
- Поле «Проект» — Vacation, UnpaidLeave, Clearance
This commit is contained in:
Тимур Городецкий
2026-08-26 15:49:25 +05:00
parent f751f03060
commit a4bc54def2
71 changed files with 749 additions and 103 deletions
@@ -102,6 +102,26 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
};
}, [editId, context]);
// Выбор другого заявителя подтягивает его ФИО (для печати) и организации (как
// в Vacation.handleEmployeeChange, но источник тот же Employee/ByEmail — здесь
// дополнительно берём ФИО, т.к. TMC хранит его отдельным полем AuthorName).
const handleRequesterChange = (login: string): void => {
setData((prev) => ({ ...prev, RequesterEmail: login }));
if (!login) {
setData((prev) => ({ ...prev, Organization: '' }));
return;
}
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
AuthorName: records[0]?.fio || prev.AuthorName,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
useEffect(() => {
if (!editId) {
@@ -131,7 +151,9 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// tmcTransferApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => tmcTransferApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context);
// Маршрут резолвится по e-mail ВЫБРАННОГО заявителя (не обязательно текущего
// пользователя) — так работает подача за другого, как в Vacation.
const userEmail = data.RequesterEmail;
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -142,13 +164,13 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
// «руководитель проекта-источника». Меняется проект → перепланируем маршрут.
const projectId = data.SourceProjectId || null;
const targetProjectId = data.TargetProjectId || null;
// Маршрут согласования зависит от проекта-источника («Откуда»): пока он не
// выбран, resolve вернёт 404 — не грузим, показываем заглушку. У открытой на
// редактирование заявки маршрут уже записан, источник не нужен.
const matrixReady = !!editId || (!!projectId && !!requestTypeId);
// Маршрут согласования зависит от заявителя и проекта-источника («Откуда»):
// пока не выбраны, resolve вернёт 404 — не грузим, показываем заглушку. У
// открытой на редактирование заявки маршрут уже записан, ни то ни другое не нужно.
const matrixReady = !!editId || (!!userEmail && !!projectId && !!requestTypeId);
// Исполнитель определяется проектом-получателем («Куда»): показываем блок с
// заглушкой, пока «Куда» не выбрано. Источник тоже нужен, иначе resolve 404.
const executorsReady = !!editId || (!!projectId && !!targetProjectId && !!requestTypeId);
// заглушкой, пока «Куда» не выбрано. Источник и заявитель тоже нужны, иначе resolve 404.
const executorsReady = !!editId || (!!userEmail && !!projectId && !!targetProjectId && !!requestTypeId);
const loadMatrix = useCallback(
() =>
editId
@@ -182,10 +204,13 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const hasItems = data.Items.some((i) => i.name.trim().length > 0);
const isValid =
data.SourceProject.trim().length > 0 && data.TargetProject.trim().length > 0 && hasItems;
!!data.RequesterEmail &&
data.SourceProject.trim().length > 0 &&
data.TargetProject.trim().length > 0 &&
hasItems;
const validate = (): string | undefined => {
if (!isValid) {
return 'Заполните обязательные поля: проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.';
return 'Заполните обязательные поля: заявитель, проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.';
}
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -278,7 +303,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const handleSubmit = (): Promise<void> =>
submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
const sectionProps = { data, set, context };
const sectionProps = { data, set, context, onRequesterChange: handleRequesterChange };
return (
<div>
@@ -294,6 +319,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
fallback={{ name: data.AuthorName, email: data.RequesterEmail }}
/>
}
headerRight={
@@ -334,7 +360,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
editor={matrix}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Выберите проект-источник («Откуда»), чтобы построить маршрут согласования."
waitingText="Укажите заявителя и проект-источник («Откуда»), чтобы построить маршрут согласования."
/>
{/* Блок исполнителя показываем, только когда по маршруту реально есть
@@ -9,6 +9,8 @@
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
@@ -12,6 +12,8 @@ import {
FormField,
AttachmentList,
PrintButton,
PrintOrgSelect,
OrgLetterhead,
Icon
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
@@ -57,6 +59,9 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
// В просмотре форма read-only, но секции ждут set — даём no-op.
@@ -128,7 +133,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
);
}
const sectionProps = { data, set, context };
const sectionProps = { data, set, context, printOrgIndex };
const notify = (): void => {
if (!requestTypeId) return;
@@ -151,10 +156,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
Редактировать
</Button>
)}
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton />
</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Служебная записка на перемещение ТМЦ"
subtitle={`№${code || itemId}`}
@@ -39,6 +39,8 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
'RequesterEmail',
'AuthorName',
'Organization',
'Department',
'DepartmentId',
'Subject',
'RequestDate',
'SourceProject',
@@ -62,6 +64,8 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
RequesterEmail: (item.RequesterEmail as string) || '',
AuthorName: (item.AuthorName as string) || '',
Organization: (item.Organization as string) || '',
Department: (item.Department as string) || '',
DepartmentId: (item.DepartmentId as string) || '',
Subject: (item.Subject as string) || '',
RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
SourceProject: (item.SourceProject as string) || '',
@@ -18,14 +18,25 @@ export interface IFormData {
// Шапка служебной записки
Recipient: string; // Кому (по умолч. «Директору Самаевой А.А.»)
// NB: не «Author» — это зарезервированное системное имя колонки SP (Created By).
RequesterEmail: string; // От — email заявителя (текущий пользователь)
AuthorName: string; // От — Ф.И.О. заявителя (для печати)
// Выбирается через PeopleField (по умолчанию — текущий пользователь, но можно
// подать за другого — см. handleRequesterChange в TmcTransferForm.tsx). Именно
// это поле идёт в getPlannedMatrix: маршрут резолвится на ВЫБРАННОГО заявителя.
RequesterEmail: string; // От — email заявителя
AuthorName: string; // От — Ф.И.О. заявителя (для печати; подтягивается из Employee/ByEmail при выборе)
/**
* Организации заявителя (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail, read-only (см.
* shared/ui/OrganizationField).
*/
Organization: string;
/**
* Подразделение (справочник Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — необязательное, справочное, на
* маршрут согласования НЕ влияет.
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
Subject: string; // Тема (по умолч. «Перемещение ТМЦ»)
RequestDate: string; // Дата (YYYY-MM-DD)
@@ -55,16 +66,27 @@ export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
/**
* Выбор заявителя подтягивает его ФИО и организации — поэтому у поля отдельный
* обработчик. Если не передан (режим чтения) — правка через set.
*/
onRequesterChange?: (login: string) => void;
}
export const DEFAULT_RECIPIENT = 'Директору Самаевой А.А.';
export const DEFAULT_SUBJECT = 'Перемещение ТМЦ';
/** Плейсхолдер для поля выбора заявителя. */
export const REQUESTER_PLACEHOLDER = 'Начните вводить Ф.И.О. заявителя…';
export const EMPTY_DATA: IFormData = {
Recipient: DEFAULT_RECIPIENT,
RequesterEmail: '',
AuthorName: '',
Organization: '',
Department: '',
DepartmentId: '',
Subject: DEFAULT_SUBJECT,
RequestDate: '',
SourceProject: '',
@@ -8,6 +8,8 @@
{ "internalName": "RequesterEmail", "title": "От (email заявителя)", "type": "Text" },
{ "internalName": "AuthorName", "title": "От (Ф.И.О.)", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "Subject", "title": "Тема", "type": "Text" },
{ "internalName": "RequestDate", "title": "Дата", "type": "DateTime", "dateOnly": true },
@@ -1,15 +1,34 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, OrganizationField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, REQUESTER_PLACEHOLDER } from '../model';
/**
* Шапка служебной записки: заявитель, проекты «откуда → куда», дата, обоснование.
* «Кому» и «Тема» — константы служебки (пишутся из модели по умолчанию), в форме не показываются.
*/
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context }) => (
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex, onRequesterChange }) => (
<FormGrid>
<OrganizationField value={data.Organization} />
<FormField label="Заявитель" htmlFor="RequesterEmail" required full>
<PeopleField
context={context}
value={data.RequesterEmail}
onChange={onRequesterChange ?? ((v) => set('RequesterEmail', v))}
placeholder={REQUESTER_PLACEHOLDER}
/>
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="Откуда (проект-источник)" htmlFor="SourceProject" required>
<ProjectPicker
id="SourceProject"