feat: выбор организации сотрудника в изменении условий ТД

Условия меняются в одном трудовом договоре, а у совместителя их несколько.
Как в командировке: пикер FullBase теперь выбирает человека (записи
склеены по ИИН), а под ним — список его мест работы «Компания — должность —
подразделение»; единственное место выбирается само. Маршрут строится по
человеку и не ждёт выбора организации. Строка под полем и вложенная сетка,
сжимавшая поля вида, убраны.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-28 14:44:16 +05:00
parent 17f9dec39b
commit 4c018154d3
12 changed files with 301 additions and 130 deletions
@@ -45,7 +45,7 @@ import {
import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, routeEmail } from './model';
import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
import { loadContractChangeRequest } from './load';
import { useSelfEmployee } from './useSelfEmployee';
import { useContractEmployee } from './useContractEmployee';
import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { useStableCallback } from '../../shared/useStableCallback';
@@ -101,15 +101,14 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
};
}, [editId, editListId, notifyCode, setSubmitError]);
// Новая заявка — по умолчанию на себя. Не перетираем, если пользователь успел
// выбрать другого, пока шёл поиск своей записи.
useSelfEmployee(context, applicantEmail, !editId, (record) =>
setData((prev) => (prev.Employee ? prev : { ...prev, Employee: record }))
// Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя.
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
set('Employee', record)
);
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail).
// У заявки на редактировании маршрут читается из уже записанных строк.
const routeUserEmail = routeEmail(data.Employee, applicantEmail);
const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
@@ -258,7 +257,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<RequestSection data={data} set={set} context={context} />
<RequestSection data={data} set={set} context={context} employeeControl={employeeControl} />
<ChangeTypeSections data={data} set={set} context={context} />
</FormModeProvider>
@@ -24,14 +24,24 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
## Особое поведение
- **Сотрудник — из `Employee/FullBase`, а не People-picker** (`EmployeeFullBasePicker`,
`shared/dictionary`): так находятся и рабочие без учётной записи. Ищется по части
Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Запись = одно место
работы: совместитель виден несколькими строками, выбирается нужная компания.
Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Записи FullBase
(одна на место работы) собираются в человека по ИИН — в поиске он одной строкой.
- **Организация — отдельный список под сотрудником**, как «Командирующая организация»
в командировке: места работы человека в виде «Компания — должность — подразделение».
Одно место — выбрано само и заблокировано. Условия меняются в ОДНОМ договоре, поэтому
в заявку пишется только выбранное место работы. Логика — `useContractEmployee`.
⚠️ На 2026-10-28 FullBase отдаёт по человеку одну запись; бэкенд доработают, чтобы
возвращались все места работы — тогда у совместителей появится выбор.
- **Хранение сотрудника — снимок в текстовых колонках `Employee*`** (Ф.И.О., ИИН,
табельный, e-mail, организация, должность, подразделение, id записи), без колонки
People. Справочник со временем меняется, а заявка фиксирует данные на момент подачи.
- **По умолчанию — на себя** (`useSelfEmployee`): Ф.И.О. из `Employee/ByEmail` →
поиск в `FullBase` → записи с e-mail текущего пользователя. Подставляется, только
если такая запись ровно одна — совместителю компанию не выбираем молча.
- **По умолчанию — на себя** (`useContractEmployee`): Ф.И.О. из `Employee/ByEmail` →
поиск в `FullBase` → человек, у которого есть место работы с e-mail текущего
пользователя. Совместителю организацию не выбираем молча — выбирает сам.
- **Черновик/возврат:** список организаций подтягивается заново (поиск по Ф.И.О.,
тот же ИИН); не нашлось — в списке только сохранённая.
- **Маршрут строится по человеку** (его e-mail), а не по месту работы — поэтому
грузится сразу после выбора сотрудника, ещё до выбора организации.
- **Вид изменения** — `ChangeTypes`, MultiChoice в SP, массив в модели. По ТЗ выбор
один (`MAX_CHANGE_TYPES`, обычный `Select`); данные уже рассчитаны на несколько,
чтобы снять ограничение правкой UI, без миграции. В коде вид — ключ
@@ -56,7 +66,7 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
- `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`.
## Секции формы
RequestSection (сотрудник · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
RequestSection (сотрудник · организация · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
## Точки входа
- rptype: `hr-contract-change`
@@ -1,6 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType } from './changeTypes';
/**
@@ -21,7 +21,10 @@ export const FORM_ID: RequestTypeId = 'hr-contract-change';
* — каждый вид своим блоком с префиксом имён (см. changeTypes.ts).
*/
export interface IFormData {
/** Сотрудник, чьи условия ТД меняются (одно место работы). undefined — не выбран. */
/**
* Место работы сотрудника, где меняются условия ТД (человек + организация —
* см. useContractEmployee). undefined — не выбран человек или ещё не выбрана организация.
*/
Employee: IEmployeeFullBaseRecord | undefined;
/**
* Выбранные виды изменения. По ТЗ ровно один (MAX_CHANGE_TYPES), массив —
@@ -64,6 +67,8 @@ export const EMPTY_DATA: IFormData = {
/** Плейсхолдер для поля выбора сотрудника. */
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Плейсхолдер для выбора организации (места работы) сотрудника. */
export const WORKPLACE_PLACEHOLDER = 'Выберите организацию, в которой меняются условия';
/** Плейсхолдер для выбора вида изменения. */
export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид изменения —';
/**
@@ -72,13 +77,14 @@ export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид измене
*/
export const ROUTE_BY_APPLICANT_NOTE =
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, вид изменения.';
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
/**
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
* записи (рабочие на объектах) — заявителя.
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
*/
export function routeEmail(employee: IEmployeeFullBaseRecord | undefined, applicantEmail: string): string {
if (!employee) return '';
return employee.email || applicantEmail;
export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
if (!person) return '';
return person.email || applicantEmail;
}
@@ -1,18 +1,44 @@
import * as React from 'react';
import { FormGrid, FormField, Select } from '../../../shared/ui';
import { FormGrid, FormField, Select, ISelectOption } from '../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { EmployeeFullBasePicker } from '../../../shared/dictionary';
import {
EmployeeFullBasePicker,
formatOrganizationLine,
personFromWorkplace,
workplaceKey
} from '../../../shared/dictionary';
import { ChangeType, CHANGE_TYPE_OPTIONS } from '../changeTypes';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, CHANGE_TYPE_PLACEHOLDER, ROUTE_BY_APPLICANT_NOTE } from '../model';
import {
ISectionProps,
EMPLOYEE_PLACEHOLDER,
WORKPLACE_PLACEHOLDER,
CHANGE_TYPE_PLACEHOLDER,
ROUTE_BY_APPLICANT_NOTE
} from '../model';
import { IContractEmployee } from '../useContractEmployee';
import styles from './RequestSection.module.scss';
interface IRequestSectionProps extends ISectionProps {
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
employeeControl?: IContractEmployee;
}
const NOOP = (): void => undefined;
/**
* Общая часть заявки, не зависящая от вида изменения: чьи условия ТД меняются и
* что именно. Поля конкретных видов — отдельными секциями (добавляются по ТЗ кадров).
* Общая часть заявки, не зависящая от вида изменения: чьи условия ТД меняются,
* в какой организации (у совместителя их несколько) и что именно меняется.
* Поля конкретных видов — отдельными секциями (byType/).
*/
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context }) => {
export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, context, employeeControl }) => {
const readOnly = useFormReadOnly();
const hasNoAccount = !!data.Employee && !data.Employee.email;
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
const workplaceOptions: ISelectOption[] = (person?.workplaces ?? []).map((w) => ({
value: workplaceKey(w),
label: formatOrganizationLine(w.company, w.position, w.department)
}));
const hasNoAccount = !!person && !person.email;
// По ТЗ вид один (MAX_CHANGE_TYPES), поэтому обычный Select: значение — первый
// элемент массива, выбор заменяет массив целиком. Под несколько видов здесь
@@ -28,12 +54,25 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context })
<EmployeeFullBasePicker
context={context}
id="Employee"
value={data.Employee}
onChange={(v) => set('Employee', v)}
value={person}
onChange={employeeControl ? employeeControl.onPersonChange : NOOP}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
{!readOnly && hasNoAccount && <div className={styles.note}>{ROUTE_BY_APPLICANT_NOTE}</div>}
</FormField>
{workplaceOptions.length > 0 && (
<FormField label="Организация" htmlFor="EmployeeWorkplace" required full>
<Select
id="EmployeeWorkplace"
value={data.Employee ? workplaceKey(data.Employee) : ''}
onChange={employeeControl ? employeeControl.onWorkplaceChange : NOOP}
options={workplaceOptions}
placeholder={WORKPLACE_PLACEHOLDER}
// Единственное место работы выбрано автоматически — выбирать не из чего (как в командировке).
readOnly={workplaceOptions.length === 1 && !!data.Employee}
/>
</FormField>
)}
<FormField label="Вид изменения" htmlFor="ChangeTypes" required full>
<Select
id="ChangeTypes"
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
IEmployeeFullBasePerson,
IEmployeeFullBaseRecord,
getEmployeeOrganizations,
groupEmployeesByPerson,
personFromWorkplace,
personKey,
searchActiveEmployees,
workplaceKey
} from '../../shared/dictionary';
// Совпадений по Ф.И.О. немного (тёзки × места работы) — запас с лихвой.
const MAX_NAMESAKE_RECORDS = 60;
/** Человек + выбор его места работы — то, что нужно секции «Сотрудник / Организация». */
export interface IContractEmployee {
person: IEmployeeFullBasePerson | undefined;
onPersonChange: (person: IEmployeeFullBasePerson | undefined) => void;
/** key — workplaceKey выбранного места работы. */
onWorkplaceChange: (key: string) => void;
}
/** Место работы, если выбирать не из чего (оно одно) — иначе выбирает пользователь. */
function singleWorkplace(person: IEmployeeFullBasePerson): IEmployeeFullBaseRecord | undefined {
return person.workplaces.length === 1 ? person.workplaces[0] : undefined;
}
/**
* Себя — по e-mail. Параметра Email у FullBase нет, поэтому: Ф.И.О. из
* Employee/ByEmail → поиск по Ф.И.О. → человек, у кого есть место работы с этим e-mail.
*/
async function findPersonByEmail(context: WebPartContext, email: string): Promise<IEmployeeFullBasePerson | undefined> {
const organizations = await getEmployeeOrganizations(context, email);
const fio = organizations[0]?.fio || '';
if (!fio) return undefined;
const people = groupEmployeesByPerson(await searchActiveEmployees(context, fio, MAX_NAMESAKE_RECORDS));
const target = email.toLowerCase();
return people.find((p) => p.workplaces.some((w) => w.email.toLowerCase() === target));
}
/** Все места работы человека, чьё одно место сохранено в заявке (черновик/возврат). */
async function findPersonOfWorkplace(
context: WebPartContext,
record: IEmployeeFullBaseRecord
): Promise<IEmployeeFullBasePerson | undefined> {
const people = groupEmployeesByPerson(await searchActiveEmployees(context, record.fio, MAX_NAMESAKE_RECORDS));
return people.find((p) => p.key === personKey(record));
}
/**
* Выбор сотрудника и его организации, как в «Заявке на командировку»: человек —
* пикером, место работы — списком; одно место выбирается само.
*
* В заявке хранится только выбранное место работы (setEmployee); человек со
* всеми местами — состояние формы, при открытии черновика подтягивается заново.
*/
export function useContractEmployee(
context: WebPartContext,
applicantEmail: string,
isNew: boolean,
employee: IEmployeeFullBaseRecord | undefined,
setEmployee: (record: IEmployeeFullBaseRecord | undefined) => void
): IContractEmployee {
const [person, setPerson] = useState<IEmployeeFullBasePerson | undefined>(undefined);
// Пользователь уже выбрал сам — фоновые поиски его выбор не перетирают.
const isTouchedRef = useRef(false);
const applyPerson = (next: IEmployeeFullBasePerson | undefined): void => {
setPerson(next);
setEmployee(next ? singleWorkplace(next) : undefined);
};
// Новая заявка — по умолчанию на себя.
useEffect(() => {
if (!isNew || !applicantEmail) return;
let alive = true;
findPersonByEmail(context, applicantEmail)
.then((found) => {
if (alive && found && !isTouchedRef.current) applyPerson(found);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context, applicantEmail, isNew]);
// Черновик/возврат: восстановить список организаций; не нашлось — хотя бы сохранённая.
const restoreKey = !isNew && employee && !person ? personKey(employee) : '';
useEffect(() => {
if (!restoreKey || !employee) return;
let alive = true;
findPersonOfWorkplace(context, employee)
.then((found) => {
if (alive && !isTouchedRef.current) setPerson(found ?? personFromWorkplace(employee));
})
.catch(() => {
if (alive && !isTouchedRef.current) setPerson(personFromWorkplace(employee));
});
return () => {
alive = false;
};
}, [context, restoreKey]);
return {
person,
onPersonChange: (next) => {
isTouchedRef.current = true;
applyPerson(next);
},
onWorkplaceChange: (key) => {
setEmployee(person?.workplaces.find((w) => workplaceKey(w) === key));
}
};
}
@@ -1,55 +0,0 @@
import { useEffect } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
IEmployeeFullBaseRecord,
getEmployeeOrganizations,
searchActiveEmployees
} from '../../shared/dictionary';
// Совпадений по Ф.И.О. немного (тёзки + совместительства) — запас с лихвой.
const MAX_NAMESAKES = 20;
/**
* Найти СВОЮ запись в Employee/FullBase. Параметра Email у эндпоинта нет, поэтому
* в два шага: Ф.И.О. из Employee/ByEmail → поиск по Ф.И.О. → записи с тем же e-mail.
*
* Возвращает запись, только если она ОДНА: у совместителя записей несколько, и
* молча подставить первую значило бы выбрать компанию за него — пусть выберет сам.
*/
async function findSelfRecord(context: WebPartContext, email: string): Promise<IEmployeeFullBaseRecord | undefined> {
const organizations = await getEmployeeOrganizations(context, email);
const fio = organizations[0]?.fio || '';
if (!fio) return undefined;
const namesakes = await searchActiveEmployees(context, fio, MAX_NAMESAKES);
const own = namesakes.filter((r) => r.email.toLowerCase() === email.toLowerCase());
return own.length === 1 ? own[0] : undefined;
}
/**
* Новая заявка — по умолчанию на себя: подставляет запись текущего пользователя
* через onFound. Не нашлась (нет в базе, совместитель, сбой справочника) — поле
* просто остаётся пустым, сотрудника выбирают вручную; это не ошибка формы.
*/
export function useSelfEmployee(
context: WebPartContext,
email: string,
enabled: boolean,
onFound: (record: IEmployeeFullBaseRecord) => void
): void {
useEffect(() => {
if (!enabled || !email) return;
let alive = true;
findSelfRecord(context, email)
.then((record) => {
if (alive && record) onFound(record);
})
.catch(() => undefined);
return () => {
alive = false;
};
// onFound намеренно не в зависимостях: подстановка — один раз при открытии новой
// заявки, а не на каждый новый экземпляр колбэка у родителя.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [context, email, enabled]);
}