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
@@ -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;
}
@@ -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';
@@ -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.
*/
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 {
@@ -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;
@@ -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<IEmployeeFullBasePickerProps> = ({
context,
@@ -54,7 +53,7 @@ export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
const effectiveReadOnly = readOnly ?? contextReadOnly;
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 [loading, setLoading] = 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;
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<IEmployeeFullBasePickerProps> = ({
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<IEmployeeFullBasePickerProps> = ({
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{value && <div className={styles.selectedMeta}>{describeRecord(value)}</div>}
{/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */}
{!effectiveReadOnly && (value || query) && (
<button
@@ -143,16 +141,16 @@ export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
<div className={styles.list}>
{statusText && <div className={loadError ? styles.error : styles.status}>{statusText}</div>}
{!statusText &&
suggestions.map((record) => (
suggestions.map((person) => (
<button
key={record.id}
key={person.key}
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(record)}
onClick={() => handlePick(person)}
>
<span className={styles.itemName}>{record.fio}</span>
<span className={styles.itemMeta}>{describeRecord(record)}</span>
<span className={styles.itemName}>{person.fio}</span>
<span className={styles.itemMeta}>{describePerson(person)}</span>
</button>
))}
</div>