feat: feat: брендированная печать, поле «Подразделение» и подача ТМЦ за другого
- Печать доступна только после согласования (Отдел рекрутинга); выбор организации и логотип-шапка для форм с несколькими юрлицами - «СЗ на перемещение ТМЦ»: заявителя можно выбрать (подача за другого), маршрут резолвится на выбранного - «Реферальная рекомендация»: организация вакансии (мультивыбор) и подразделение/проект вакансии - Поле «Подразделение» (справочник) — Vacation, UnpaidLeave, Clearance, WorkplaceSetup, RecruitingHire, RecruitingSelection, TmcTransfer - Поле «Проект» — Vacation, UnpaidLeave, Clearance
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user