feat: выбор сотрудника из полной кадровой базы (Employee/FullBase)
PeoplePicker находит только тех, у кого есть учётка M365, — рабочих на объектах в нём нет. Пикер ищет по части Ф.И.О. в Employee/FullBase, показывает только работающих, а совместителя — строкой на каждое место работы, чтобы сразу выбрать нужную компанию. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user