feat: добавил поле организация во все заявки, а также расширил компонет выбора даты для ui-kit

This commit is contained in:
Тимур Городецкий
2026-08-25 11:43:57 +05:00
parent a146877327
commit 3b4ee93b48
55 changed files with 641 additions and 143 deletions
@@ -26,6 +26,7 @@ import {
unassignedStepMessage
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -100,19 +101,29 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
setData((prev) => ({ ...prev, TerminationDate: prev.TerminationDate || today }));
}, [editId]);
// Должность/подразделение больше не вводятся вручную — подтягиваются из
// SharePoint User Profile (SPS-JobTitle/SPS-Department) при выборе сотрудника.
// Если профиль не синхронизирован и поле там пусто — пока просто остаётся
// пустым (TODO: фолбэк на справочник api.tnsu.kz, если это окажется частой
// проблемой на практике).
// Подразделение подтягивается из SharePoint User Profile (SPS-Department) при
// выборе сотрудника — если профиль не синхронизирован, поле остаётся пустым.
// Должность и организации — из Employee/ByEmail (см. shared/dictionary/
// employeeOrganizations.service.ts), а не из профиля: сотрудник может состоять
// сразу в нескольких организациях, должность берём из ПЕРВОЙ записи, чтобы
// Должность и Организация оставались парой из одной записи.
const handleEmployeeChange = (login: string): void => {
setData((prev) => ({ ...prev, Employee: login, Position: '', Department: '' }));
setData((prev) => ({ ...prev, Employee: login, Position: '', Department: '', Organization: '' }));
if (!login) {
return;
}
approval
.getProfileFields(login)
.then((profile) => setData((prev) => ({ ...prev, Position: profile.jobTitle, Department: profile.department })))
.then((profile) => setData((prev) => ({ ...prev, Department: profile.department })))
.catch(() => undefined);
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
@@ -31,6 +31,7 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'ParkingCard',
'AccessCard',
'TrainingAmount',
'Organization',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
@@ -50,7 +51,8 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
ParkingCard: !!item.ParkingCard,
AccessCard: !!item.AccessCard,
TrainingAmount: amount === null || amount === undefined ? '' : String(amount)
TrainingAmount: amount === null || amount === undefined ? '' : String(amount),
Organization: (item.Organization as string) || ''
};
return {
@@ -25,6 +25,12 @@ export interface IFormData {
AccessCard: boolean;
/** Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не указана. */
TrainingAmount: string;
/**
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail при выборе Employee,
* read-only (см. shared/ui/OrganizationField).
*/
Organization: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
@@ -49,7 +55,8 @@ export const EMPTY_DATA: IFormData = {
TerminationDate: '',
ParkingCard: false,
AccessCard: false,
TrainingAmount: ''
TrainingAmount: '',
Organization: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -11,6 +11,7 @@
{ "internalName": "ParkingCard", "title": "Парковочная карта", "type": "Boolean" },
{ "internalName": "AccessCard", "title": "Карта СКУД", "type": "Boolean" },
{ "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 }
{ "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 },
{ "internalName": "Organization", "title": "Организация", "type": "Note" }
]
}
@@ -1,5 +1,5 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
// Подразделение подтягивается автоматически при выборе сотрудника (см.
@@ -54,6 +54,7 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
placeholder="Подставится автоматически по выбранному сотруднику"
/>
</FormField>
<OrganizationField value={data.Organization} />
<FormField label="Дата расторжения договора" htmlFor="TerminationDate" required>
<TextInput
id="TerminationDate"
@@ -67,7 +67,8 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TerminationDate: data.TerminationDate || null,
ParkingCard: data.ParkingCard,
AccessCard: data.AccessCard,
TrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null
TrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null,
Organization: data.Organization || null
};
}
@@ -16,13 +16,13 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
return (
<CollapseSection title="Заявка" icon="clipboard">
<FormField label="Компания" htmlFor="Companies" full>
<FormField label="Организация" htmlFor="Companies" full>
<CompanyPickerMulti
id="Companies"
context={context}
value={data.Companies}
onChange={(companies) => set('Companies', companies)}
placeholder="Начните вводить название компании…"
placeholder="Начните вводить название организации…"
/>
</FormField>
<FormField label="Заявка на подбор" full>
@@ -58,13 +58,13 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
/>
</FormField>
)}
<FormField label="1.5 Компания" htmlFor="Companies" full>
<FormField label="1.5 Организация" htmlFor="Companies" full>
<CompanyPickerMulti
context={context}
id="Companies"
value={data.Companies}
onChange={(v) => set('Companies', v)}
placeholder="Начните вводить название компании…"
placeholder="Начните вводить название организации…"
/>
</FormField>
<FormField label="1.6 Объект/Проект" htmlFor="ObjectProject" full>
@@ -27,6 +27,7 @@ import {
initialSubmitStatus
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -94,9 +95,10 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId, editListId]);
// Новая заявка: рекомендатель — сам подающий (подставляем его, должность и
// подразделение из профиля SharePoint — SPS-JobTitle/SPS-Department, как в
// «Обходном листе»), дата подачи — сегодня.
// Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение —
// из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и
// организации — из Employee/ByEmail, см. shared/dictionary/
// employeeOrganizations.service.ts), дата подачи — сегодня.
useEffect(() => {
if (editId) {
return;
@@ -114,24 +116,35 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
if (alive) {
setData((prev) => ({
...prev,
ReferrerPosition: prev.ReferrerPosition || profile.jobTitle,
ReferrerDepartment: prev.ReferrerDepartment || profile.department
}));
}
})
.catch(() => undefined);
getEmployeeOrganizations(context, user.email)
.then((records) => {
if (!alive) return;
setData((prev) => ({
...prev,
ReferrerPosition: prev.ReferrerPosition || records[0]?.position || prev.ReferrerPosition,
Organization: formatEmployeeOrganizations(records)
}));
})
.catch(() => undefined);
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
// Выбор другого рекомендателя подтягивает его должность и подразделение из
// профиля — оба поля остаются редактируемыми (фолбэк, если профиль не
// синхронизирован), поэтому не затираем то, что уже введено, пустым значением.
// Выбор другого рекомендателя подтягивает его подразделение из профиля,
// должность и организации — из Employee/ByEmail. Оба поля (Position/
// Department) остаются редактируемыми (фолбэк, если источник недоступен),
// поэтому не затираем то, что уже введено, пустым значением.
const handleReferrerChange = (login: string): void => {
setData((prev) => ({ ...prev, Referrer: login }));
if (!login) {
setData((prev) => ({ ...prev, Organization: '' }));
return;
}
approval
@@ -139,11 +152,19 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
.then((profile) =>
setData((prev) => ({
...prev,
ReferrerPosition: profile.jobTitle || prev.ReferrerPosition,
ReferrerDepartment: profile.department || prev.ReferrerDepartment
}))
)
.catch(() => undefined);
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
ReferrerPosition: records[0]?.position || prev.ReferrerPosition,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
// Планируемый маршрут резолвится по e-mail РЕКОМЕНДАТЕЛЯ (кандидата в тенанте
@@ -29,6 +29,7 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom
'ReferrerPosition',
'ReferrerDepartment',
'ReferrerPhone',
'Organization',
'CandidateName',
'CandidatePhone',
'CandidateEmail',
@@ -62,6 +63,7 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom
ReferrerPosition: (item.ReferrerPosition as string) || '',
ReferrerDepartment: (item.ReferrerDepartment as string) || '',
ReferrerPhone: (item.ReferrerPhone as string) || '',
Organization: (item.Organization as string) || '',
CandidateName: (item.CandidateName as string) || '',
CandidatePhone: (item.CandidatePhone as string) || '',
CandidateEmail: (item.CandidateEmail as string) || '',
@@ -42,6 +42,12 @@ export interface IFormData {
ReferrerPosition: string;
ReferrerDepartment: string;
ReferrerPhone: string;
/**
* Организации рекомендателя (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail при выборе Referrer,
* read-only (см. shared/ui/OrganizationField).
*/
Organization: string;
CandidateName: string;
CandidatePhone: string;
@@ -87,6 +93,7 @@ export const EMPTY_DATA: IFormData = {
ReferrerPosition: '',
ReferrerDepartment: '',
ReferrerPhone: '',
Organization: '',
CandidateName: '',
CandidatePhone: '',
CandidateEmail: '',
@@ -9,6 +9,7 @@
{ "internalName": "ReferrerPosition", "title": "Должность рекомендателя", "type": "Text" },
{ "internalName": "ReferrerDepartment", "title": "Подразделение / Объект рекомендателя", "type": "Text" },
{ "internalName": "ReferrerPhone", "title": "Контактный телефон рекомендателя", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "CandidateName", "title": "ФИО кандидата", "type": "Text", "required": true },
{ "internalName": "CandidatePhone", "title": "Контактный телефон кандидата", "type": "Text" },
@@ -1,5 +1,5 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, PeopleField } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, PhoneInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { ISectionProps, REFERRER_PLACEHOLDER } from '../model';
/** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */
@@ -31,6 +31,7 @@ export const ReferrerSection: React.FC<ISectionProps> = ({ data, set, context, o
placeholder="Подставится автоматически по выбранному сотруднику"
/>
</FormField>
<OrganizationField value={data.Organization} />
<FormField label="Контактный телефон" htmlFor="ReferrerPhone" required>
<PhoneInput id="ReferrerPhone" value={data.ReferrerPhone} onChange={(v) => set('ReferrerPhone', v)} />
</FormField>
@@ -33,6 +33,7 @@ function buildItem(data: IFormData, status: string, referrerId: number): Record<
ReferrerPosition: data.ReferrerPosition || null,
ReferrerDepartment: data.ReferrerDepartment || null,
ReferrerPhone: data.ReferrerPhone || null,
Organization: data.Organization || null,
CandidateName: data.CandidateName || null,
CandidatePhone: data.CandidatePhone || null,
CandidateEmail: data.CandidateEmail || null,
@@ -36,6 +36,7 @@ import {
IAttachmentInfo
} from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IRequestFormProps } from '../../models/types';
import {
@@ -77,15 +78,28 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
}
const user = context.pageContext.user;
const today = new Date().toISOString().slice(0, 10);
const requesterEmail = effectiveUserEmail(context);
setData((prev) => ({
...prev,
// email — через effectiveUserEmail (учитывает отладочную подмену личности);
// displayName подменить нечем, справочник ФИО тут не читается — при
// подмене имя останется настоящим, для отладки это неважно.
RequesterEmail: effectiveUserEmail(context),
RequesterEmail: requesterEmail,
AuthorName: user.displayName || '',
RequestDate: prev.RequestDate || today
}));
// Организации заявителя (Employee/ByEmail) — read-only, см. shared/ui/OrganizationField.
let alive = true;
getEmployeeOrganizations(context, requesterEmail)
.then((records) => {
if (alive) {
setData((prev) => ({ ...prev, Organization: formatEmployeeOrganizations(records) }));
}
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [editId, context]);
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
@@ -38,6 +38,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
'Recipient',
'RequesterEmail',
'AuthorName',
'Organization',
'Subject',
'RequestDate',
'SourceProject',
@@ -60,6 +61,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
Recipient: (item.Recipient as string) || '',
RequesterEmail: (item.RequesterEmail as string) || '',
AuthorName: (item.AuthorName as string) || '',
Organization: (item.Organization as string) || '',
Subject: (item.Subject as string) || '',
RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
SourceProject: (item.SourceProject as string) || '',
@@ -20,6 +20,12 @@ export interface IFormData {
// NB: не «Author» — это зарезервированное системное имя колонки SP (Created By).
RequesterEmail: string; // От — email заявителя (текущий пользователь)
AuthorName: string; // От — Ф.И.О. заявителя (для печати)
/**
* Организации заявителя (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail, read-only (см.
* shared/ui/OrganizationField).
*/
Organization: string;
Subject: string; // Тема (по умолч. «Перемещение ТМЦ»)
RequestDate: string; // Дата (YYYY-MM-DD)
@@ -58,6 +64,7 @@ export const EMPTY_DATA: IFormData = {
Recipient: DEFAULT_RECIPIENT,
RequesterEmail: '',
AuthorName: '',
Organization: '',
Subject: DEFAULT_SUBJECT,
RequestDate: '',
SourceProject: '',
@@ -7,6 +7,7 @@
{ "internalName": "Recipient", "title": "Кому", "type": "Text" },
{ "internalName": "RequesterEmail", "title": "От (email заявителя)", "type": "Text" },
{ "internalName": "AuthorName", "title": "От (Ф.И.О.)", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Subject", "title": "Тема", "type": "Text" },
{ "internalName": "RequestDate", "title": "Дата", "type": "DateTime", "dateOnly": true },
@@ -1,5 +1,5 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui';
import { FormGrid, FormField, TextInput, TextArea, OrganizationField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
@@ -9,6 +9,7 @@ import { ISectionProps } from '../model';
*/
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<FormGrid>
<OrganizationField value={data.Organization} />
<FormField label="Откуда (проект-источник)" htmlFor="SourceProject" required>
<ProjectPicker
id="SourceProject"
@@ -25,14 +25,16 @@ import {
resolveWriteListId
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model';
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
import { loadUnpaidLeaveRequest } from './load';
import { RequestSection } from './sections/RequestSection';
@@ -88,7 +90,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId, editListId]);
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
// нескольких организациях, должность берём из ПЕРВОЙ записи).
useEffect(() => {
if (editId) {
return;
@@ -96,12 +101,14 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
let alive = true;
const user = context.pageContext.user;
setData((prev) => ({ ...prev, Employee: prev.Employee || user.email }));
approval
.getJobTitle(user.loginName)
.then((title) => {
if (alive && title) {
setData((prev) => ({ ...prev, Position: prev.Position || title }));
}
getEmployeeOrganizations(context, user.email)
.then((records) => {
if (!alive) return;
setData((prev) => ({
...prev,
Position: prev.Position || records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}));
})
.catch(() => undefined);
return () => {
@@ -110,15 +117,21 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
// Выбор другого сотрудника подтягивает его должность.
// Выбор другого сотрудника подтягивает его должность и организации.
const handleEmployeeChange = (login: string): void => {
setData((prev) => ({ ...prev, Employee: login }));
if (!login) {
setData((prev) => ({ ...prev, Organization: '' }));
return;
}
approval
.getJobTitle(login)
.then((title) => setData((prev) => ({ ...prev, Position: title || prev.Position })))
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
@@ -142,13 +155,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// Даты корректны, если конец не раньше начала (обе обязательны).
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
const isValid = !!(
data.Employee &&
data.Position.trim() &&
datesValid &&
isStartDateValid &&
data.Reason.trim()
);
const validate = (): string | undefined => {
if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.';
}
@@ -120,7 +120,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
<div className="rp-print-root">
<FormCard
title="Отпуск без содержания"
title="Заявка на отпуск без содержания"
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -29,6 +29,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
'StartDate',
'EndDate',
'Reason',
'Organization',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
@@ -49,7 +50,8 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
EndDate: end.slice(0, 10),
Reason: (item.Reason as string) || '',
Substitute: substitute?.EMail || ''
Substitute: substitute?.EMail || '',
Organization: (item.Organization as string) || ''
};
return {
@@ -27,6 +27,12 @@ export interface IFormData {
Reason: string;
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
Substitute: string;
/**
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail при выборе Employee,
* read-only (см. shared/ui/OrganizationField).
*/
Organization: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
@@ -50,10 +56,13 @@ export const EMPTY_DATA: IFormData = {
StartDate: '',
EndDate: '',
Reason: '',
Substitute: ''
Substitute: '',
Organization: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Плейсхолдер для поля выбора замещающего. */
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
export const MIN_NOTICE_DAYS = 14;
@@ -10,6 +10,7 @@
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" }
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" }
]
}
@@ -1,10 +1,11 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, PeopleField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER } from '../model';
import { CollapseSection, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
<CollapseSection title="Отпуск без содержания" icon="people">
<CollapseSection title="Заявка на отпуск без содержания" icon="people">
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
@@ -16,8 +17,15 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<OrganizationField value={data.Organization} />
<FormField label="Дата начала" htmlFor="StartDate" required>
<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)}
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="EndDate" required>
<TextInput id="EndDate" type="date" value={data.EndDate} onChange={(v) => set('EndDate', v)} />
@@ -48,7 +48,8 @@ function buildItem(
EndDate: data.EndDate || null,
DaysCount: days ?? null,
Reason: data.Reason || null,
SubstituteId: substituteId ?? null
SubstituteId: substituteId ?? null,
Organization: data.Organization || null
};
}
@@ -25,14 +25,16 @@ import {
unassignedStepMessage
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model';
import { saveVacationRequest, vacationApprovalService } from './submit';
import { loadVacationRequest } from './load';
import { RequestSection } from './sections/RequestSection';
@@ -88,7 +90,10 @@ const VacationForm: React.FC<IRequestFormProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId, editListId]);
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
// нескольких организациях, должность берём из ПЕРВОЙ записи).
useEffect(() => {
if (editId) {
return;
@@ -96,12 +101,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({
let alive = true;
const user = context.pageContext.user;
setData((prev) => ({ ...prev, Employee: prev.Employee || user.email }));
approval
.getJobTitle(user.loginName)
.then((title) => {
if (alive && title) {
setData((prev) => ({ ...prev, Position: prev.Position || title }));
}
getEmployeeOrganizations(context, user.email)
.then((records) => {
if (!alive) return;
setData((prev) => ({
...prev,
Position: prev.Position || records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}));
})
.catch(() => undefined);
return () => {
@@ -110,15 +117,22 @@ const VacationForm: React.FC<IRequestFormProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
// Выбор другого сотрудника подтягивает его должность (как в исходной pto-tracker).
// Выбор другого сотрудника подтягивает его должность и организации (как в
// исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle).
const handleEmployeeChange = (login: string): void => {
setData((prev) => ({ ...prev, Employee: login }));
if (!login) {
setData((prev) => ({ ...prev, Organization: '' }));
return;
}
approval
.getJobTitle(login)
.then((title) => setData((prev) => ({ ...prev, Position: title || prev.Position })))
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
@@ -141,14 +155,23 @@ const VacationForm: React.FC<IRequestFormProps> = ({
);
const daysNum = Number(data.DaysCount);
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
const isValid = !!(
data.Employee &&
data.Position.trim() &&
data.StartDate &&
isStartDateValid &&
Number.isFinite(daysNum) &&
daysNum > 0
);
const validate = (): string | undefined => {
if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -120,7 +120,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
<div className="rp-print-root">
<FormCard
title="Заявка на отпуск"
title="Заявка на трудовой отпуск"
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -28,14 +28,17 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
'Position',
'StartDate',
'DaysCount',
'Organization',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
'Employee/EMail'
'Employee/EMail',
'Substitute/EMail'
)
.expand('Employee')()) as Record<string, unknown>;
.expand('Employee', 'Substitute')()) as Record<string, unknown>;
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
const substitute = item.Substitute as { EMail?: string } | undefined;
const start = (item.StartDate as string) || '';
const days = item.DaysCount;
@@ -44,7 +47,9 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
Employee: employee?.EMail || '',
Position: (item.Position as string) || '',
StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
DaysCount: days === null || days === undefined ? '' : String(days)
DaysCount: days === null || days === undefined ? '' : String(days),
Substitute: substitute?.EMail || '',
Organization: (item.Organization as string) || ''
};
return {
@@ -22,6 +22,16 @@ export interface IFormData {
StartDate: string;
/** Строка из input type=number; при записи → number. */
DaysCount: string;
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
Substitute: string;
/**
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail (см.
* shared/dictionary/employeeOrganizations.service.ts) при выборе Employee,
* read-only (см. shared/ui/OrganizationField). Сотрудник может состоять
* сразу в нескольких организациях — тогда строк несколько.
*/
Organization: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
@@ -43,8 +53,14 @@ export const EMPTY_DATA: IFormData = {
Employee: '',
Position: '',
StartDate: '',
DaysCount: ''
DaysCount: '',
Substitute: '',
Organization: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
export const MIN_NOTICE_DAYS = 14;
/** Плейсхолдер для поля выбора замещающего. */
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
@@ -8,6 +8,8 @@
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" }
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" }
]
}
@@ -1,10 +1,11 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PeopleField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
import { CollapseSection, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
<CollapseSection title="Заявка на отпуск" icon="people">
<CollapseSection title="Заявка на трудовой отпуск" icon="people">
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
@@ -16,11 +17,26 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<OrganizationField value={data.Organization} />
<FormField label="Дата начала" htmlFor="StartDate" required>
<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)}
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
/>
</FormField>
<FormField label="Количество дней" htmlFor="DaysCount" required>
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
</FormField>
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
<PeopleField
context={context}
value={data.Substitute}
onChange={(v) => set('Substitute', v)}
placeholder={SUBSTITUTE_PLACEHOLDER}
/>
</FormField>
</CollapseSection>
);
@@ -29,8 +29,14 @@ function buildTitle(employeeName: string): string {
return `Отпуск — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
}
/** Поля элемента списка данных из модели формы (+ Employee по Id, + вычисленная EndDate). */
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленная EndDate). */
function buildItem(
data: IFormData,
status: string,
employeeId: number,
employeeName: string,
substituteId: number | undefined
): Record<string, unknown> {
const days = Number(data.DaysCount);
return {
Title: buildTitle(employeeName),
@@ -39,7 +45,9 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
Position: data.Position || null,
StartDate: data.StartDate || null,
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
EndDate: computeEndDate(data.StartDate, days) || null
EndDate: computeEndDate(data.StartDate, days) || null,
SubstituteId: substituteId ?? null,
Organization: data.Organization || null
};
}
@@ -68,7 +76,9 @@ export async function saveVacationRequest(
listId?: string
): Promise<ISaveResult> {
const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
// Замещающий необязателен: резолвим только если выбран.
const substituteId = data.Substitute ? (await ensureEmployee(data.Substitute)).Id : undefined;
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
@@ -36,6 +36,7 @@ import {
IAttachmentInfo
} from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IRequestFormProps } from '../../models/types';
import {
@@ -111,7 +112,10 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
loadPrefillFromHire(sourceId)
.then((prefilled) => {
if (alive) {
setData(prefilled);
// Merge, а не replace: организация заявителя (ниже) резолвится отдельным
// эффектом и могла успеть записаться раньше — не затираем её пустышкой
// из prefilled (в loadPrefillFromHire её нет).
setData((prev) => ({ ...prefilled, Organization: prev.Organization || prefilled.Organization }));
}
})
.catch((e) => {
@@ -129,6 +133,30 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
// workplaceApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => workplaceApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context);
// Организации заявителя (Employee/ByEmail) — форма не о самом заявителе, а о
// новом сотруднике (FullName — обычный текст, предзаполняется из «Заявки на
// прием», и его чаще всего ещё нет в системе кадров), поэтому организация
// тянется по ТЕКУЩЕМУ пользователю. На редактировании уже сохранённое
// значение не перезатираем.
useEffect(() => {
if (editId || !userEmail) {
return;
}
let alive = true;
getEmployeeOrganizations(context, userEmail)
.then((records) => {
if (alive) {
setData((prev) => ({ ...prev, Organization: prev.Organization || formatEmployeeOrganizations(records) }));
}
})
.catch(() => undefined);
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId, userEmail]);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -4,7 +4,7 @@ import { FORM_ID, IFormData, EMPTY_DATA } from './model';
const SCALAR_FIELDS: (keyof IFormData)[] = [
'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'WorkKind', 'Location', 'StartDate',
'Software', 'AccessRights'
'Software', 'AccessRights', 'Organization'
];
export interface ILoadedRequest {
@@ -24,6 +24,13 @@ export interface IFormData {
WorkKind: string;
Location: string;
StartDate: string;
/**
* Организации заявителя (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail по e-mail заявителя (не
* нового сотрудника — его ещё нет в системе кадров), read-only (см.
* shared/ui/OrganizationField).
*/
Organization: string;
// Оборудование и доступы (заполняются вручную)
NeedsEquipment: boolean;
Software: string;
@@ -50,6 +57,7 @@ export const EMPTY_DATA: IFormData = {
WorkKind: '',
Location: '',
StartDate: '',
Organization: '',
NeedsEquipment: false,
Software: '',
AccessRights: ''
@@ -13,6 +13,7 @@
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
{ "internalName": "Organization", "title": "Организация заявителя", "type": "Note" },
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
@@ -1,5 +1,5 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, PhoneInput, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
@@ -37,5 +37,6 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context })
<FormField label="Дата приёма" htmlFor="StartDate">
<TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} />
</FormField>
<OrganizationField value={data.Organization} label="Организация заявителя" />
</CollapseSection>
);