feat: выбор организации сотрудника в изменении условий ТД
Условия меняются в одном трудовом договоре, а у совместителя их несколько. Как в командировке: пикер FullBase теперь выбирает человека (записи склеены по ИИН), а под ним — список его мест работы «Компания — должность — подразделение»; единственное место выбирается само. Маршрут строится по человеку и не ждёт выбора организации. Строка под полем и вложенная сетка, сжимавшая поля вида, убраны. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user