diff --git a/src/webparts/requestPortal/shared/dictionary/employeeFullBase.service.ts b/src/webparts/requestPortal/shared/dictionary/employeeFullBase.service.ts new file mode 100644 index 0000000..7991c09 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/employeeFullBase.service.ts @@ -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): 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 { + 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>; + return body + .map(toRecord) + .filter((r) => !r.dismissalDate) + .slice(0, maxResults); +} diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index 9c322b0..c4039e7 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -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'; diff --git a/src/webparts/requestPortal/shared/dictionary/models/IEmployeeFullBaseRecord.ts b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeFullBaseRecord.ts new file mode 100644 index 0000000..5a2af8c --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeFullBaseRecord.ts @@ -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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.module.scss b/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.module.scss new file mode 100644 index 0000000..e90c5c7 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.tsx b/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.tsx new file mode 100644 index 0000000..604c0b6 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/EmployeeFullBasePicker.tsx @@ -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 = ({ + context, + id, + value, + onChange, + placeholder, + readOnly +}) => { + const contextReadOnly = useFormReadOnly(); + const effectiveReadOnly = readOnly ?? contextReadOnly; + + const [query, setQuery] = 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); + // Счётчик запросов — ответ на устаревший запрос не перезаписывает более свежий (как в 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 ( +
+ handleInput(e.target.value)} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + /> + {value &&
{describeRecord(value)}
} + {/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */} + {!effectiveReadOnly && (value || query) && ( + + )} + {isListShown && ( +
+ {statusText &&
{statusText}
} + {!statusText && + suggestions.map((record) => ( + + ))} +
+ )} +
+ ); +}; + +/** Текст служебной строки списка; '' — показать сами подсказки. */ +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 ''; +}