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 { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, routeEmail } from './model';
import { saveContractChangeRequest, contractChangeApprovalService } from './submit'; import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
import { loadContractChangeRequest } from './load'; import { loadContractChangeRequest } from './load';
import { useSelfEmployee } from './useSelfEmployee'; import { useContractEmployee } from './useContractEmployee';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections'; import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
@@ -101,15 +101,14 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
}; };
}, [editId, editListId, notifyCode, setSubmitError]); }, [editId, editListId, notifyCode, setSubmitError]);
// Новая заявка — по умолчанию на себя. Не перетираем, если пользователь успел // Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя.
// выбрать другого, пока шёл поиск своей записи. const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
useSelfEmployee(context, applicantEmail, !editId, (record) => set('Employee', record)
setData((prev) => (prev.Employee ? prev : { ...prev, Employee: record }))
); );
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail). // Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail).
// У заявки на редактировании маршрут читается из уже записанных строк. // У заявки на редактировании маршрут читается из уже записанных строк.
const routeUserEmail = routeEmail(data.Employee, applicantEmail); const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () => loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
@@ -258,7 +257,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}> <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} /> <ChangeTypeSections data={data} set={set} context={context} />
</FormModeProvider> </FormModeProvider>
@@ -24,14 +24,24 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
## Особое поведение ## Особое поведение
- **Сотрудник — из `Employee/FullBase`, а не People-picker** (`EmployeeFullBasePicker`, - **Сотрудник — из `Employee/FullBase`, а не People-picker** (`EmployeeFullBasePicker`,
`shared/dictionary`): так находятся и рабочие без учётной записи. Ищется по части `shared/dictionary`): так находятся и рабочие без учётной записи. Ищется по части
Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Запись = одно место Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Записи FullBase
работы: совместитель виден несколькими строками, выбирается нужная компания. (одна на место работы) собираются в человека по ИИН — в поиске он одной строкой.
- **Организация — отдельный список под сотрудником**, как «Командирующая организация»
в командировке: места работы человека в виде «Компания — должность — подразделение».
Одно место — выбрано само и заблокировано. Условия меняются в ОДНОМ договоре, поэтому
в заявку пишется только выбранное место работы. Логика — `useContractEmployee`.
⚠️ На 2026-10-28 FullBase отдаёт по человеку одну запись; бэкенд доработают, чтобы
возвращались все места работы — тогда у совместителей появится выбор.
- **Хранение сотрудника — снимок в текстовых колонках `Employee*`** (Ф.И.О., ИИН, - **Хранение сотрудника — снимок в текстовых колонках `Employee*`** (Ф.И.О., ИИН,
табельный, e-mail, организация, должность, подразделение, id записи), без колонки табельный, e-mail, организация, должность, подразделение, id записи), без колонки
People. Справочник со временем меняется, а заявка фиксирует данные на момент подачи. People. Справочник со временем меняется, а заявка фиксирует данные на момент подачи.
- **По умолчанию — на себя** (`useSelfEmployee`): Ф.И.О. из `Employee/ByEmail` → - **По умолчанию — на себя** (`useContractEmployee`): Ф.И.О. из `Employee/ByEmail` →
поиск в `FullBase` → записи с e-mail текущего пользователя. Подставляется, только поиск в `FullBase` → человек, у которого есть место работы с e-mail текущего
если такая запись ровно одна — совместителю компанию не выбираем молча. пользователя. Совместителю организацию не выбираем молча — выбирает сам.
- **Черновик/возврат:** список организаций подтягивается заново (поиск по Ф.И.О.,
тот же ИИН); не нашлось — в списке только сохранённая.
- **Маршрут строится по человеку** (его e-mail), а не по месту работы — поэтому
грузится сразу после выбора сотрудника, ещё до выбора организации.
- **Вид изменения** — `ChangeTypes`, MultiChoice в SP, массив в модели. По ТЗ выбор - **Вид изменения** — `ChangeTypes`, MultiChoice в SP, массив в модели. По ТЗ выбор
один (`MAX_CHANGE_TYPES`, обычный `Select`); данные уже рассчитаны на несколько, один (`MAX_CHANGE_TYPES`, обычный `Select`); данные уже рассчитаны на несколько,
чтобы снять ограничение правкой UI, без миграции. В коде вид — ключ чтобы снять ограничение правкой UI, без миграции. В коде вид — ключ
@@ -56,7 +66,7 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
- `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`. - `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`.
## Секции формы ## Секции формы
RequestSection (сотрудник · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам») RequestSection (сотрудник · организация · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
## Точки входа ## Точки входа
- rptype: `hr-contract-change` - rptype: `hr-contract-change`
@@ -1,6 +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 { IEmployeeFullBaseRecord } from '../../shared/dictionary'; import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType } from './changeTypes'; import { ChangeType } from './changeTypes';
/** /**
@@ -21,7 +21,10 @@ export const FORM_ID: RequestTypeId = 'hr-contract-change';
* — каждый вид своим блоком с префиксом имён (см. changeTypes.ts). * — каждый вид своим блоком с префиксом имён (см. changeTypes.ts).
*/ */
export interface IFormData { export interface IFormData {
/** Сотрудник, чьи условия ТД меняются (одно место работы). undefined — не выбран. */ /**
* Место работы сотрудника, где меняются условия ТД (человек + организация —
* см. useContractEmployee). undefined — не выбран человек или ещё не выбрана организация.
*/
Employee: IEmployeeFullBaseRecord | undefined; Employee: IEmployeeFullBaseRecord | undefined;
/** /**
* Выбранные виды изменения. По ТЗ ровно один (MAX_CHANGE_TYPES), массив — * Выбранные виды изменения. По ТЗ ровно один (MAX_CHANGE_TYPES), массив —
@@ -64,6 +67,8 @@ export const EMPTY_DATA: IFormData = {
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Плейсхолдер для выбора организации (места работы) сотрудника. */
export const WORKPLACE_PLACEHOLDER = 'Выберите организацию, в которой меняются условия';
/** Плейсхолдер для выбора вида изменения. */ /** Плейсхолдер для выбора вида изменения. */
export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид изменения —'; export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид изменения —';
/** /**
@@ -72,13 +77,14 @@ export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид измене
*/ */
export const ROUTE_BY_APPLICANT_NOTE = export const ROUTE_BY_APPLICANT_NOTE =
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.'; 'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, вид изменения.'; export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
/** /**
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной * E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
* записи (рабочие на объектах) — заявителя. * записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
*/ */
export function routeEmail(employee: IEmployeeFullBaseRecord | undefined, applicantEmail: string): string { export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
if (!employee) return ''; if (!person) return '';
return employee.email || applicantEmail; return person.email || applicantEmail;
} }
@@ -1,18 +1,44 @@
import * as React from 'react'; 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 { 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 { 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'; 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 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: значение — первый // По ТЗ вид один (MAX_CHANGE_TYPES), поэтому обычный Select: значение — первый
// элемент массива, выбор заменяет массив целиком. Под несколько видов здесь // элемент массива, выбор заменяет массив целиком. Под несколько видов здесь
@@ -28,12 +54,25 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context })
<EmployeeFullBasePicker <EmployeeFullBasePicker
context={context} context={context}
id="Employee" id="Employee"
value={data.Employee} value={person}
onChange={(v) => set('Employee', v)} onChange={employeeControl ? employeeControl.onPersonChange : NOOP}
placeholder={EMPLOYEE_PLACEHOLDER} placeholder={EMPLOYEE_PLACEHOLDER}
/> />
{!readOnly && hasNoAccount && <div className={styles.note}>{ROUTE_BY_APPLICANT_NOTE}</div>} {!readOnly && hasNoAccount && <div className={styles.note}>{ROUTE_BY_APPLICANT_NOTE}</div>}
</FormField> </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> <FormField label="Вид изменения" htmlFor="ChangeTypes" required full>
<Select <Select
id="ChangeTypes" 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]);
}
@@ -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 { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord'; import { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
import { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson';
// null из API → '' (см. IEmployeeFullBaseRecord). // null из API → '' (см. IEmployeeFullBaseRecord).
function textOrEmpty(value: unknown): string { function textOrEmpty(value: unknown): string {
@@ -58,3 +59,36 @@ export async function searchActiveEmployees(
.filter((r) => !r.dismissalDate) .filter((r) => !r.dismissalDate)
.slice(0, maxResults); .slice(0, maxResults);
} }
/** Чей это человек: ИИН, а без него — id записи (тогда запись считается отдельным человеком). */
export function personKey(record: IEmployeeFullBaseRecord): string {
return record.iin || record.id;
}
/**
* Уникальный ключ места работы — для value в выпадающем списке. Не только id:
* у совместителя на каждое место работы может прийти один и тот же id человека.
*/
export function workplaceKey(record: IEmployeeFullBaseRecord): string {
return `${record.id}|${record.company}|${record.position}`;
}
/** Человек из одной записи — когда остальные его места работы неизвестны (напр. просмотр заявки). */
export function personFromWorkplace(record: IEmployeeFullBaseRecord): IEmployeeFullBasePerson {
return { key: personKey(record), fio: record.fio, iin: record.iin, email: record.email, workplaces: [record] };
}
/** Записи FullBase → люди (по ИИН), порядок — по первому появлению человека в ответе. */
export function groupEmployeesByPerson(records: IEmployeeFullBaseRecord[]): IEmployeeFullBasePerson[] {
const people: IEmployeeFullBasePerson[] = [];
records.forEach((record) => {
const person = people.find((p) => p.key === personKey(record));
if (!person) {
people.push(personFromWorkplace(record));
return;
}
person.workplaces.push(record);
if (!person.email) person.email = record.email;
});
return people;
}
@@ -19,8 +19,15 @@ export {
employeeCompanies employeeCompanies
} from './employeeOrganizations.service'; } from './employeeOrganizations.service';
export type { IEmployeeOrganization } from './models/IEmployeeOrganization'; export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
export { searchActiveEmployees } from './employeeFullBase.service'; export {
searchActiveEmployees,
groupEmployeesByPerson,
personKey,
personFromWorkplace,
workplaceKey
} from './employeeFullBase.service';
export type { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord'; export type { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
export type { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson';
export { EmployeeFullBasePicker } from './ui/EmployeeFullBasePicker'; export { EmployeeFullBasePicker } from './ui/EmployeeFullBasePicker';
export type { IEmployeeFullBasePickerProps } from './ui/EmployeeFullBasePicker'; export type { IEmployeeFullBasePickerProps } from './ui/EmployeeFullBasePicker';
export { getNomenclature } from './nomenclature.service'; export { getNomenclature } from './nomenclature.service';
@@ -37,5 +44,9 @@ export {
} from './employeeLookup.service'; } from './employeeLookup.service';
export { CityPicker } from './ui/CityPicker'; export { CityPicker } from './ui/CityPicker';
export type { ICityPickerProps, ICityValue } from './ui/CityPicker'; export type { ICityPickerProps, ICityValue } from './ui/CityPicker';
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines'; export {
formatEmployeeOrganizationsWithDepartment,
formatOrganizationLine,
parseOrganizationLine
} from './organizationLines';
export type { IOrganizationLine } from './organizationLines'; export type { IOrganizationLine } from './organizationLines';
@@ -0,0 +1,17 @@
import { IEmployeeFullBaseRecord } from './IEmployeeFullBaseRecord';
/**
* Человек из Employee/FullBase — все его записи (места работы), собранные по ИИН.
* Пикер выбирает человека, а конкретное место работы форма выбирает отдельно
* (у совместителя их несколько) — как «Командирующая организация» в командировке.
*/
export interface IEmployeeFullBasePerson {
/** Ключ группировки: ИИН, а если его нет — id записи (см. personKey). */
key: string;
fio: string;
iin: string;
/** Первый непустой e-mail среди мест работы; '' — нет учётной записи. */
email: string;
/** Места работы, минимум одно. */
workplaces: IEmployeeFullBaseRecord[];
}
@@ -8,12 +8,17 @@ import { IEmployeeOrganization } from './models/IEmployeeOrganization';
* формат прежний, как у formatEmployeeOrganizations. * формат прежний, как у formatEmployeeOrganizations.
*/ */
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string { export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
return records return records.map((r) => formatOrganizationLine(r.company, r.position, r.section)).join('\n');
.map((r) => { }
if (r.section) return [r.company, r.position, r.section].join(' — ');
return r.position ? `${r.company} — ${r.position}` : r.company; /**
}) * Одна строка «Компания — должность[ — подразделение]» — тот же формат, что у
.join('\n'); * formatEmployeeOrganizationsWithDepartment, для источников кроме Employee/ByEmail
* (напр. запись Employee/FullBase).
*/
export function formatOrganizationLine(company: string, position: string, department: string): string {
if (department) return [company, position, department].join(' — ');
return position ? `${company} — ${position}` : company;
} }
export interface IOrganizationLine { export interface IOrganizationLine {
@@ -27,20 +27,11 @@
} }
} }
// Под выбранным Ф.И.О. — где именно работает (компания · должность · подразделение).
.selectedMeta {
margin-top: $sp-1;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
overflow-wrap: anywhere;
}
.clear { .clear {
position: absolute; position: absolute;
// По верхнему полю, а не по центру .wrap: под полем может быть строка .selectedMeta. top: 50%;
top: 10px;
right: 8px; right: 8px;
transform: translateY(-50%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -2,22 +2,23 @@ import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './EmployeeFullBasePicker.module.scss'; import styles from './EmployeeFullBasePicker.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit'; import { useFormReadOnly } from '@tnsu/ui-kit';
import { searchActiveEmployees } from '../employeeFullBase.service'; import { searchActiveEmployees, groupEmployeesByPerson } from '../employeeFullBase.service';
import { IEmployeeFullBaseRecord } from '../models/IEmployeeFullBaseRecord'; import { IEmployeeFullBasePerson } from '../models/IEmployeeFullBasePerson';
export interface IEmployeeFullBasePickerProps { export interface IEmployeeFullBasePickerProps {
context: WebPartContext; context: WebPartContext;
id?: string; id?: string;
/** Выбранная запись (одно место работы сотрудника); undefined — не выбран. */ /** Выбранный человек (со всеми местами работы); undefined — не выбран. */
value: IEmployeeFullBaseRecord | undefined; value: IEmployeeFullBasePerson | undefined;
onChange: (value: IEmployeeFullBaseRecord | undefined) => void; onChange: (value: IEmployeeFullBasePerson | undefined) => void;
placeholder?: string; placeholder?: string;
/** Только чтение (режим согласующего). */ /** Только чтение (режим согласующего). */
readOnly?: boolean; readOnly?: boolean;
} }
const DEBOUNCE_MS = 300; const DEBOUNCE_MS = 300;
const MAX_RESULTS = 30; // Записей у совместителя несколько на человека — берём с запасом, людей в списке потом меньше.
const MAX_RECORDS = 60;
// По 1–2 буквам совпадений тысячи — запрос тяжёлый, а выбрать из такого списка всё равно нельзя. // По 1–2 буквам совпадений тысячи — запрос тяжёлый, а выбрать из такого списка всё равно нельзя.
const MIN_QUERY_LENGTH = 3; const MIN_QUERY_LENGTH = 3;
@@ -26,21 +27,19 @@ const TEXT_TYPE_MORE = `Введите не меньше ${MIN_QUERY_LENGTH} б
const TEXT_NOT_FOUND = 'Работающих сотрудников с таким Ф.И.О. не найдено'; const TEXT_NOT_FOUND = 'Работающих сотрудников с таким Ф.И.О. не найдено';
const TEXT_LOAD_ERROR = 'Не удалось загрузить список сотрудников. Попробуйте ещё раз.'; const TEXT_LOAD_ERROR = 'Не удалось загрузить список сотрудников. Попробуйте ещё раз.';
/** «Компания · должность · подразделение» — чтобы различить совместителя и тёзок. */ /** Компании человека через запятую — чтобы различать тёзок. */
function describeRecord(record: IEmployeeFullBaseRecord): string { function describePerson(person: IEmployeeFullBasePerson): string {
return [record.company, record.position, record.department].filter(Boolean).join(' · '); return person.workplaces.map((w) => w.company).filter(Boolean).join(', ');
} }
/** /**
* Выбор сотрудника из полной кадровой базы (GET /Employee/FullBase) — в отличие * Выбор сотрудника из полной кадровой базы (GET /Employee/FullBase) — в отличие
* от PeoplePicker находит и тех, у кого нет учётной записи M365. * от PeoplePicker находит и тех, у кого нет учётной записи M365.
* *
* Значение — запись целиком, а не текст: ручной ввод не принимается, иначе у * Значение — человек целиком (все места работы, собранные по ИИН), а не текст:
* заявки не будет ИИН, компании и e-mail. Начали править текст после выбора — * ручной ввод не принимается, иначе у заявки не будет ИИН, компании и e-mail.
* выбор сбрасывается (onChange(undefined)) и снова идёт поиск. * Начали править текст после выбора — выбор сбрасывается (onChange(undefined)).
* * Конкретное место работы (организацию) форма выбирает отдельным полем.
* Совместитель виден несколькими строками (по месту работы) — выбирается то
* место, к которому относится заявка.
*/ */
export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({ export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
context, context,
@@ -54,7 +53,7 @@ export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
const effectiveReadOnly = readOnly ?? contextReadOnly; const effectiveReadOnly = readOnly ?? contextReadOnly;
const [query, setQuery] = React.useState<string>(''); const [query, setQuery] = React.useState<string>('');
const [suggestions, setSuggestions] = React.useState<IEmployeeFullBaseRecord[]>([]); const [suggestions, setSuggestions] = React.useState<IEmployeeFullBasePerson[]>([]);
const [open, setOpen] = React.useState<boolean>(false); const [open, setOpen] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<boolean>(false); const [loading, setLoading] = React.useState<boolean>(false);
const [loadError, setLoadError] = React.useState<boolean>(false); const [loadError, setLoadError] = React.useState<boolean>(false);
@@ -74,10 +73,10 @@ export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
const requestId = ++requestIdRef.current; const requestId = ++requestIdRef.current;
setLoading(true); setLoading(true);
const timer = setTimeout(() => { const timer = setTimeout(() => {
searchActiveEmployees(context, trimmedQuery, MAX_RESULTS) searchActiveEmployees(context, trimmedQuery, MAX_RECORDS)
.then((list) => { .then((records) => {
if (requestIdRef.current !== requestId) return; if (requestIdRef.current !== requestId) return;
setSuggestions(list); setSuggestions(groupEmployeesByPerson(records));
setLoadError(false); setLoadError(false);
setLoading(false); setLoading(false);
}) })
@@ -97,8 +96,8 @@ export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
setOpen(true); setOpen(true);
}; };
const handlePick = (record: IEmployeeFullBaseRecord): void => { const handlePick = (person: IEmployeeFullBasePerson): void => {
onChange(record); onChange(person);
setQuery(''); setQuery('');
setOpen(false); setOpen(false);
}; };
@@ -125,7 +124,6 @@ export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)} onBlur={() => setOpen(false)}
/> />
{value && <div className={styles.selectedMeta}>{describeRecord(value)}</div>}
{/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */} {/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */}
{!effectiveReadOnly && (value || query) && ( {!effectiveReadOnly && (value || query) && (
<button <button
@@ -143,16 +141,16 @@ export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
<div className={styles.list}> <div className={styles.list}>
{statusText && <div className={loadError ? styles.error : styles.status}>{statusText}</div>} {statusText && <div className={loadError ? styles.error : styles.status}>{statusText}</div>}
{!statusText && {!statusText &&
suggestions.map((record) => ( suggestions.map((person) => (
<button <button
key={record.id} key={person.key}
type="button" type="button"
className={styles.item} className={styles.item}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(record)} onClick={() => handlePick(person)}
> >
<span className={styles.itemName}>{record.fio}</span> <span className={styles.itemName}>{person.fio}</span>
<span className={styles.itemMeta}>{describeRecord(record)}</span> <span className={styles.itemMeta}>{describePerson(person)}</span>
</button> </button>
))} ))}
</div> </div>