From 4c018154d3ad37a33b717e3d1586b807df643b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Wed, 28 Oct 2026 14:44:16 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BE?= =?UTF-8?q?=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=B8=D0=B9=20=D0=A2=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Условия меняются в одном трудовом договоре, а у совместителя их несколько. Как в командировке: пикер FullBase теперь выбирает человека (записи склеены по ИИН), а под ним — список его мест работы «Компания — должность — подразделение»; единственное место выбирается само. Маршрут строится по человеку и не ждёт выбора организации. Строка под полем и вложенная сетка, сжимавшая поля вида, убраны. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ContractChange/ContractChangeForm.tsx | 13 +- .../forms/ContractChange/README.md | 22 +++- .../forms/ContractChange/model.ts | 20 +-- .../sections/RequestSection.tsx | 57 +++++++-- .../ContractChange/useContractEmployee.ts | 116 ++++++++++++++++++ .../forms/ContractChange/useSelfEmployee.ts | 55 --------- .../dictionary/employeeFullBase.service.ts | 34 +++++ .../requestPortal/shared/dictionary/index.ts | 15 ++- .../models/IEmployeeFullBasePerson.ts | 17 +++ .../shared/dictionary/organizationLines.ts | 17 ++- .../ui/EmployeeFullBasePicker.module.scss | 13 +- .../dictionary/ui/EmployeeFullBasePicker.tsx | 52 ++++---- 12 files changed, 301 insertions(+), 130 deletions(-) create mode 100644 src/webparts/requestPortal/forms/ContractChange/useContractEmployee.ts delete mode 100644 src/webparts/requestPortal/forms/ContractChange/useSelfEmployee.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/models/IEmployeeFullBasePerson.ts diff --git a/src/webparts/requestPortal/forms/ContractChange/ContractChangeForm.tsx b/src/webparts/requestPortal/forms/ContractChange/ContractChangeForm.tsx index b317840..dec8cb2 100644 --- a/src/webparts/requestPortal/forms/ContractChange/ContractChangeForm.tsx +++ b/src/webparts/requestPortal/forms/ContractChange/ContractChangeForm.tsx @@ -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 = ({ }; }, [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 = ({ {submit.error && {submit.error}} - + diff --git a/src/webparts/requestPortal/forms/ContractChange/README.md b/src/webparts/requestPortal/forms/ContractChange/README.md index 551a720..0fb02fd 100644 --- a/src/webparts/requestPortal/forms/ContractChange/README.md +++ b/src/webparts/requestPortal/forms/ContractChange/README.md @@ -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` diff --git a/src/webparts/requestPortal/forms/ContractChange/model.ts b/src/webparts/requestPortal/forms/ContractChange/model.ts index ef19a64..3e8bfdc 100644 --- a/src/webparts/requestPortal/forms/ContractChange/model.ts +++ b/src/webparts/requestPortal/forms/ContractChange/model.ts @@ -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; } diff --git a/src/webparts/requestPortal/forms/ContractChange/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/ContractChange/sections/RequestSection.tsx index d4489d1..af4df03 100644 --- a/src/webparts/requestPortal/forms/ContractChange/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/ContractChange/sections/RequestSection.tsx @@ -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 = ({ data, set, context }) => { +export const RequestSection: React.FC = ({ 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 = ({ data, set, context }) set('Employee', v)} + value={person} + onChange={employeeControl ? employeeControl.onPersonChange : NOOP} placeholder={EMPLOYEE_PLACEHOLDER} /> {!readOnly && hasNoAccount &&
{ROUTE_BY_APPLICANT_NOTE}
} + {workplaceOptions.length > 0 && ( + + 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 { + 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 { + 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(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)); + } + }; +} diff --git a/src/webparts/requestPortal/forms/ContractChange/useSelfEmployee.ts b/src/webparts/requestPortal/forms/ContractChange/useSelfEmployee.ts deleted file mode 100644 index fe7db0a..0000000 --- a/src/webparts/requestPortal/forms/ContractChange/useSelfEmployee.ts +++ /dev/null @@ -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 { - 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]); -} diff --git a/src/webparts/requestPortal/shared/dictionary/employeeFullBase.service.ts b/src/webparts/requestPortal/shared/dictionary/employeeFullBase.service.ts index 7991c09..ca26c42 100644 --- a/src/webparts/requestPortal/shared/dictionary/employeeFullBase.service.ts +++ b/src/webparts/requestPortal/shared/dictionary/employeeFullBase.service.ts @@ -2,6 +2,7 @@ import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config'; import { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord'; +import { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson'; // null из API → '' (см. IEmployeeFullBaseRecord). function textOrEmpty(value: unknown): string { @@ -58,3 +59,36 @@ export async function searchActiveEmployees( .filter((r) => !r.dismissalDate) .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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index c4039e7..0bc1683 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -19,8 +19,15 @@ export { employeeCompanies } from './employeeOrganizations.service'; 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 { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson'; export { EmployeeFullBasePicker } from './ui/EmployeeFullBasePicker'; export type { IEmployeeFullBasePickerProps } from './ui/EmployeeFullBasePicker'; export { getNomenclature } from './nomenclature.service'; @@ -37,5 +44,9 @@ export { } from './employeeLookup.service'; export { CityPicker } 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'; diff --git a/src/webparts/requestPortal/shared/dictionary/models/IEmployeeFullBasePerson.ts b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeFullBasePerson.ts new file mode 100644 index 0000000..413181f --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeFullBasePerson.ts @@ -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[]; +} diff --git a/src/webparts/requestPortal/shared/dictionary/organizationLines.ts b/src/webparts/requestPortal/shared/dictionary/organizationLines.ts index c3e9432..606de65 100644 --- a/src/webparts/requestPortal/shared/dictionary/organizationLines.ts +++ b/src/webparts/requestPortal/shared/dictionary/organizationLines.ts @@ -8,12 +8,17 @@ import { IEmployeeOrganization } from './models/IEmployeeOrganization'; * формат прежний, как у formatEmployeeOrganizations. */ export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string { - return records - .map((r) => { - if (r.section) return [r.company, r.position, r.section].join(' — '); - return r.position ? `${r.company} — ${r.position}` : r.company; - }) - .join('\n'); + return records.map((r) => formatOrganizationLine(r.company, r.position, r.section)).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 { diff --git a/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.module.scss b/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.module.scss index e90c5c7..be32eb1 100644 --- a/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.module.scss +++ b/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.module.scss @@ -27,20 +27,11 @@ } } -// Под выбранным Ф.И.О. — где именно работает (компания · должность · подразделение). -.selectedMeta { - margin-top: $sp-1; - font-family: $font-sans; - font-size: $fs-sm; - color: $gray-500; - overflow-wrap: anywhere; -} - .clear { position: absolute; - // По верхнему полю, а не по центру .wrap: под полем может быть строка .selectedMeta. - top: 10px; + top: 50%; right: 8px; + transform: translateY(-50%); display: flex; align-items: center; justify-content: center; diff --git a/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.tsx b/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.tsx index 604c0b6..882d21c 100644 --- a/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.tsx +++ b/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.tsx @@ -2,22 +2,23 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './EmployeeFullBasePicker.module.scss'; import { useFormReadOnly } from '@tnsu/ui-kit'; -import { searchActiveEmployees } from '../employeeFullBase.service'; -import { IEmployeeFullBaseRecord } from '../models/IEmployeeFullBaseRecord'; +import { searchActiveEmployees, groupEmployeesByPerson } from '../employeeFullBase.service'; +import { IEmployeeFullBasePerson } from '../models/IEmployeeFullBasePerson'; export interface IEmployeeFullBasePickerProps { context: WebPartContext; id?: string; - /** Выбранная запись (одно место работы сотрудника); undefined — не выбран. */ - value: IEmployeeFullBaseRecord | undefined; - onChange: (value: IEmployeeFullBaseRecord | undefined) => void; + /** Выбранный человек (со всеми местами работы); undefined — не выбран. */ + value: IEmployeeFullBasePerson | undefined; + onChange: (value: IEmployeeFullBasePerson | undefined) => void; placeholder?: string; /** Только чтение (режим согласующего). */ readOnly?: boolean; } const DEBOUNCE_MS = 300; -const MAX_RESULTS = 30; +// Записей у совместителя несколько на человека — берём с запасом, людей в списке потом меньше. +const MAX_RECORDS = 60; // По 1–2 буквам совпадений тысячи — запрос тяжёлый, а выбрать из такого списка всё равно нельзя. const MIN_QUERY_LENGTH = 3; @@ -26,21 +27,19 @@ const TEXT_TYPE_MORE = `Введите не меньше ${MIN_QUERY_LENGTH} б const TEXT_NOT_FOUND = 'Работающих сотрудников с таким Ф.И.О. не найдено'; const TEXT_LOAD_ERROR = 'Не удалось загрузить список сотрудников. Попробуйте ещё раз.'; -/** «Компания · должность · подразделение» — чтобы различить совместителя и тёзок. */ -function describeRecord(record: IEmployeeFullBaseRecord): string { - return [record.company, record.position, record.department].filter(Boolean).join(' · '); +/** Компании человека через запятую — чтобы различать тёзок. */ +function describePerson(person: IEmployeeFullBasePerson): string { + return person.workplaces.map((w) => w.company).filter(Boolean).join(', '); } /** * Выбор сотрудника из полной кадровой базы (GET /Employee/FullBase) — в отличие * от PeoplePicker находит и тех, у кого нет учётной записи M365. * - * Значение — запись целиком, а не текст: ручной ввод не принимается, иначе у - * заявки не будет ИИН, компании и e-mail. Начали править текст после выбора — - * выбор сбрасывается (onChange(undefined)) и снова идёт поиск. - * - * Совместитель виден несколькими строками (по месту работы) — выбирается то - * место, к которому относится заявка. + * Значение — человек целиком (все места работы, собранные по ИИН), а не текст: + * ручной ввод не принимается, иначе у заявки не будет ИИН, компании и e-mail. + * Начали править текст после выбора — выбор сбрасывается (onChange(undefined)). + * Конкретное место работы (организацию) форма выбирает отдельным полем. */ export const EmployeeFullBasePicker: React.FC = ({ context, @@ -54,7 +53,7 @@ export const EmployeeFullBasePicker: React.FC = ({ const effectiveReadOnly = readOnly ?? contextReadOnly; const [query, setQuery] = React.useState(''); - const [suggestions, setSuggestions] = React.useState([]); + const [suggestions, setSuggestions] = React.useState([]); const [open, setOpen] = React.useState(false); const [loading, setLoading] = React.useState(false); const [loadError, setLoadError] = React.useState(false); @@ -74,10 +73,10 @@ export const EmployeeFullBasePicker: React.FC = ({ const requestId = ++requestIdRef.current; setLoading(true); const timer = setTimeout(() => { - searchActiveEmployees(context, trimmedQuery, MAX_RESULTS) - .then((list) => { + searchActiveEmployees(context, trimmedQuery, MAX_RECORDS) + .then((records) => { if (requestIdRef.current !== requestId) return; - setSuggestions(list); + setSuggestions(groupEmployeesByPerson(records)); setLoadError(false); setLoading(false); }) @@ -97,8 +96,8 @@ export const EmployeeFullBasePicker: React.FC = ({ setOpen(true); }; - const handlePick = (record: IEmployeeFullBaseRecord): void => { - onChange(record); + const handlePick = (person: IEmployeeFullBasePerson): void => { + onChange(person); setQuery(''); setOpen(false); }; @@ -125,7 +124,6 @@ export const EmployeeFullBasePicker: React.FC = ({ onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} /> - {value &&
{describeRecord(value)}
} {/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */} {!effectiveReadOnly && (value || query) && ( ))}