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
|
||||
} from './employeeOrganizations.service';
|
||||
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 type { INomenclatureRow } from './models/INomenclatureRow';
|
||||
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