feat: выбор сотрудника из полной кадровой базы (Employee/FullBase)

PeoplePicker находит только тех, у кого есть учётка M365, — рабочих на
объектах в нём нет. Пикер ищет по части Ф.И.О. в Employee/FullBase,
показывает только работающих, а совместителя — строкой на каждое место
работы, чтобы сразу выбрать нужную компанию.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-28 12:10:42 +05:00
parent 82a9a68fbc
commit 9f97a19cab
5 changed files with 384 additions and 0 deletions
@@ -0,0 +1,60 @@
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';
// null из API → '' (см. IEmployeeFullBaseRecord).
function textOrEmpty(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(r: Record<string, unknown>): IEmployeeFullBaseRecord {
return {
id: textOrEmpty(r.id),
fio: textOrEmpty(r.fio),
iin: textOrEmpty(r.iin),
code: textOrEmpty(r.code),
company: textOrEmpty(r.company),
// Должности в базе бывают с хвостовым пробелом («Монтажник ») — textOrEmpty его срезает.
position: textOrEmpty(r.position),
department: textOrEmpty(r.department),
// В API опечатка «admision» — переименовываем на входе, дальше по коду правильное имя.
admissionDate: textOrEmpty(r.admisionDate),
dismissalDate: textOrEmpty(r.dismissalDate),
email: textOrEmpty(r.email)
};
}
/**
* GET /Employee/FullBase?Fio=... — работающие сотрудники по части Ф.И.О.
*
* Без фильтра эндпоинт отдаёт всю базу (тысячи записей, с уволенными), поэтому
* ищем на сервере по мере ввода, как PositionPicker — и ничего не кешируем.
*
* Уволенных отсекаем по dismissalDate на клиенте. Параметр Status у эндпоинта
* есть, но его значения не задокументированы — ДОПУЩЕНИЕ, проверить и, если он
* фильтрует работающих, перейти на него.
*/
export async function searchActiveEmployees(
context: WebPartContext,
fio: string,
maxResults: number
): Promise<IEmployeeFullBaseRecord[]> {
const query = fio.trim();
if (!query) return [];
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Employee/FullBase?Fio=${encodeURIComponent(query)}`,
AadHttpClient.configurations.v1
);
if (response.status === 404) return [];
if (!response.ok) {
throw new Error(`Employee/FullBase: ${response.status} ${response.statusText}`);
}
const body = (await response.json()) as Array<Record<string, unknown>>;
return body
.map(toRecord)
.filter((r) => !r.dismissalDate)
.slice(0, maxResults);
}
@@ -19,6 +19,10 @@ 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 type { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
export { EmployeeFullBasePicker } from './ui/EmployeeFullBasePicker';
export type { IEmployeeFullBasePickerProps } from './ui/EmployeeFullBasePicker';
export { getNomenclature } from './nomenclature.service'; export { getNomenclature } from './nomenclature.service';
export type { INomenclatureRow } from './models/INomenclatureRow'; export type { INomenclatureRow } from './models/INomenclatureRow';
export { searchPositions } from './positions.service'; export { searchPositions } from './positions.service';
@@ -0,0 +1,29 @@
/**
* Одна запись из GET /Employee/FullBase — полная база сотрудников из кадровой
* системы, ВКЛЮЧАЯ уволенных и рабочих без учётной записи M365.
*
* Запись = одно место работы, а не человек: совместитель приходит несколькими
* записями с одним iin и разными company/position/department.
*
* В отличие от Employee/ByEmail (поля с большой буквы), этот эндпоинт отдаёт
* camelCase — так в ответе API. null из API приводится к '' (как в
* IEmployeeOrganization): «нет значения» везде — пустая строка.
*/
export interface IEmployeeFullBaseRecord {
id: string;
/** «Фамилия Имя Отчество». */
fio: string;
/** ИИН, 12 цифр. */
iin: string;
/** Табельный номер в 1С (напр. «ЗК-0073429»). */
code: string;
company: string;
position: string;
department: string;
/** Дата приёма, «YYYY-MM-DDT00:00:00»; '' — не указана. */
admissionDate: string;
/** Дата увольнения; '' — работает. */
dismissalDate: string;
/** Рабочий e-mail; '' — у сотрудника нет учётной записи (рабочие на объектах). */
email: string;
}
@@ -0,0 +1,120 @@
@import '../../../../../styles/design-tokens';
// Поле и выпадающий список — в той же стилистике, что PositionPicker.
.wrap {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
// справа место под крестик-очистку
padding: 11px 36px 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
// Под выбранным Ф.И.О. — где именно работает (компания · должность · подразделение).
.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;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-400;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 300px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.itemName {
font-size: $fs-sm;
color: $gray-900;
}
.itemMeta {
font-size: 12px;
color: $gray-500;
overflow-wrap: anywhere;
}
.status,
.error {
padding: 9px 12px;
font-family: $font-sans;
font-size: $fs-sm;
}
.status {
color: $gray-500;
}
.error {
color: $red-500;
}
@@ -0,0 +1,171 @@
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';
export interface IEmployeeFullBasePickerProps {
context: WebPartContext;
id?: string;
/** Выбранная запись (одно место работы сотрудника); undefined — не выбран. */
value: IEmployeeFullBaseRecord | undefined;
onChange: (value: IEmployeeFullBaseRecord | undefined) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const DEBOUNCE_MS = 300;
const MAX_RESULTS = 30;
// По 1–2 буквам совпадений тысячи — запрос тяжёлый, а выбрать из такого списка всё равно нельзя.
const MIN_QUERY_LENGTH = 3;
const TEXT_SEARCHING = 'Поиск…';
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(' · ');
}
/**
* Выбор сотрудника из полной кадровой базы (GET /Employee/FullBase) — в отличие
* от PeoplePicker находит и тех, у кого нет учётной записи M365.
*
* Значение — запись целиком, а не текст: ручной ввод не принимается, иначе у
* заявки не будет ИИН, компании и e-mail. Начали править текст после выбора —
* выбор сбрасывается (onChange(undefined)) и снова идёт поиск.
*
* Совместитель виден несколькими строками (по месту работы) — выбирается то
* место, к которому относится заявка.
*/
export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [query, setQuery] = React.useState<string>('');
const [suggestions, setSuggestions] = React.useState<IEmployeeFullBaseRecord[]>([]);
const [open, setOpen] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<boolean>(false);
const [loadError, setLoadError] = React.useState<boolean>(false);
// Счётчик запросов — ответ на устаревший запрос не перезаписывает более свежий (как в PositionPicker).
const requestIdRef = React.useRef(0);
const trimmedQuery = query.trim();
const isQueryTooShort = trimmedQuery.length < MIN_QUERY_LENGTH;
React.useEffect(() => {
if (isQueryTooShort) {
setSuggestions([]);
setLoading(false);
setLoadError(false);
return;
}
const requestId = ++requestIdRef.current;
setLoading(true);
const timer = setTimeout(() => {
searchActiveEmployees(context, trimmedQuery, MAX_RESULTS)
.then((list) => {
if (requestIdRef.current !== requestId) return;
setSuggestions(list);
setLoadError(false);
setLoading(false);
})
.catch(() => {
if (requestIdRef.current !== requestId) return;
setSuggestions([]);
setLoadError(true);
setLoading(false);
});
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [context, trimmedQuery, isQueryTooShort]);
const handleInput = (text: string): void => {
if (value) onChange(undefined);
setQuery(text);
setOpen(true);
};
const handlePick = (record: IEmployeeFullBaseRecord): void => {
onChange(record);
setQuery('');
setOpen(false);
};
const handleClear = (): void => {
onChange(undefined);
setQuery('');
setOpen(false);
};
const statusText = getStatusText(loading, loadError, isQueryTooShort, suggestions.length);
const isListShown = !effectiveReadOnly && open && !value && trimmedQuery.length > 0;
return (
<div className={styles.wrap}>
<input
id={id}
className={styles.input}
value={value ? value.fio : query}
placeholder={placeholder}
readOnly={effectiveReadOnly}
autoComplete="off"
onChange={(e) => handleInput(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{value && <div className={styles.selectedMeta}>{describeRecord(value)}</div>}
{/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */}
{!effectiveReadOnly && (value || query) && (
<button
type="button"
className={styles.clear}
aria-label="Очистить"
title="Очистить"
onMouseDown={(e) => e.preventDefault()}
onClick={handleClear}
>
×
</button>
)}
{isListShown && (
<div className={styles.list}>
{statusText && <div className={loadError ? styles.error : styles.status}>{statusText}</div>}
{!statusText &&
suggestions.map((record) => (
<button
key={record.id}
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(record)}
>
<span className={styles.itemName}>{record.fio}</span>
<span className={styles.itemMeta}>{describeRecord(record)}</span>
</button>
))}
</div>
)}
</div>
);
};
/** Текст служебной строки списка; '' — показать сами подсказки. */
function getStatusText(loading: boolean, loadError: boolean, isQueryTooShort: boolean, found: number): string {
if (isQueryTooShort) return TEXT_TYPE_MORE;
if (loading) return TEXT_SEARCHING;
if (loadError) return TEXT_LOAD_ERROR;
if (found === 0) return TEXT_NOT_FOUND;
return '';
}