diff --git a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json index 220b411..c722004 100644 --- a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json +++ b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json @@ -25,7 +25,8 @@ "apiBaseUrl": "http://localhost:5080", "useDevAuth": true, "devUserEmail": "dev@tnsu.kz", - "aadResourceId": "api://9232d9e2-9807-4753-808f-07250e5474b0" + "aadResourceId": "api://9232d9e2-9807-4753-808f-07250e5474b0", + "portalBaseUrl": "https://api.tnsu.kz" } }] } diff --git a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts index 4a8e467..bb1c75d 100644 --- a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts +++ b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts @@ -16,6 +16,7 @@ export interface IApprovalAdminWebPartProps { useDevAuth: boolean; devUserEmail: string; aadResourceId: string; + portalBaseUrl: string; } export default class ApprovalAdminWebPart extends BaseClientSideWebPart { @@ -26,7 +27,8 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart = props => { const [api, setApi] = React.useState(); + const [dictionary, setDictionary] = React.useState(); const [error, setError] = React.useState(); React.useEffect(() => { @@ -45,6 +47,24 @@ const ApprovalAdmin: React.FC = props => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]); + // Справочники всегда идут через AAD, независимо от Dev-режима нашего API: + // api.tnsu.kz принимает только настоящий токен, заголовок X-Dev-User-Email + // ему ничего не говорит. + React.useEffect(() => { + let cancelled = false; + setDictionary(undefined); + + props.context.aadHttpClientFactory + .getClient(props.aadResourceId) + .then(client => { + if (!cancelled) setDictionary(new PortalDictionaryClient(client, props.portalBaseUrl)); + }) + .catch(() => { /* экран маршрутов покажет ошибку загрузки справочника */ }); + + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.aadResourceId, props.portalBaseUrl]); + return (
{error && ( @@ -55,7 +75,13 @@ const ApprovalAdmin: React.FC = props => { )} {!error && !api &&
Подключение к API...
} - {api && } + {api && ( + + )}
); }; diff --git a/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts b/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts index 00f42d1..e01a6e5 100644 --- a/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts +++ b/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts @@ -6,4 +6,5 @@ export interface IApprovalAdminProps { useDevAuth: boolean; devUserEmail: string; aadResourceId: string; + portalBaseUrl: string; } diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 63ff990..0af5664 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -3,6 +3,7 @@ import styles from './AdminApp.module.scss'; import { TopNav } from './TopNav'; import { ScreenKey } from './types'; import { ApprovalRouteApi } from '../../services/api'; +import { PortalDictionaryClient } from '../../services/portalDictionary'; import { TemplateRoute } from '../../models'; import { DashboardScreen } from '../screens/DashboardScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen'; @@ -12,10 +13,12 @@ import { ResolveScreen } from '../screens/ResolveScreen'; export interface IAdminAppProps { api: ApprovalRouteApi; + /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ + dictionary?: PortalDictionaryClient; currentUserEmail?: string; } -export const AdminApp: React.FC = ({ api, currentUserEmail }) => { +export const AdminApp: React.FC = ({ api, dictionary, currentUserEmail }) => { const [screen, setScreen] = React.useState('dashboard'); const [selectedRoute, setSelectedRoute] = React.useState(); const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState(''); @@ -38,7 +41,7 @@ export const AdminApp: React.FC = ({ api, currentUserEmail }) =>
{screen === 'dashboard' && } {screen === 'requestTypes' && } - {screen === 'routes' && } + {screen === 'routes' && } {screen === 'routeSteps' && selectedRoute && ( void; } @@ -48,8 +50,12 @@ const ROUTE_TYPE_OPTIONS = Object.values(RouteType) .filter(v => v !== RouteType.None) .map(v => ({ value: v, label: routeTypeLabels[v] })); -export const RoutesScreen: React.FC = ({ api, onOpenSteps }) => { +export const RoutesScreen: React.FC = ({ api, dictionary, onOpenSteps }) => { const [requestTypes, setRequestTypes] = React.useState([]); + const [companies, setCompanies] = React.useState([]); + const [departments, setDepartments] = React.useState([]); + const [dictLoading, setDictLoading] = React.useState(true); + const [dictError, setDictError] = React.useState(); const [items, setItems] = React.useState([]); const [filterRequestTypeId, setFilterRequestTypeId] = React.useState(''); const [loading, setLoading] = React.useState(true); @@ -69,6 +75,22 @@ export const RoutesScreen: React.FC = ({ api, onOpenSteps }) api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается в общей ошибке ниже */ }); }, [api]); + // Справочники 1С грузим один раз на экран: они статичны, а отделов ~700 — + // поиск делаем на клиенте, без запроса на каждый символ. + React.useEffect(() => { + if (!dictionary) return; + + setDictLoading(true); + setDictError(undefined); + Promise.all([dictionary.companies(), dictionary.departments()]) + .then(([loadedCompanies, loadedDepartments]) => { + setCompanies(loadedCompanies); + setDepartments(loadedDepartments); + }) + .catch((e: ApiClientError) => setDictError(e.message)) + .finally(() => setDictLoading(false)); + }, [dictionary]); + const load = React.useCallback(() => { setLoading(true); setLoadError(undefined); @@ -84,6 +106,19 @@ export const RoutesScreen: React.FC = ({ api, onOpenSteps }) const requestTypeName = (id: number): string => requestTypes.find(rt => rt.id === id)?.name ?? `#${id}`; + // Пока справочник не загрузился (или маршрут ссылается на удалённую запись), + // показываем усечённый Guid — чтобы строка таблицы не выглядела пустой. + const companyName = (id: string): string => companies.find(c => c.id === id)?.name ?? shortGuid(id); + + const departmentName = (id: string): string => departments.find(d => d.id === id)?.name ?? shortGuid(id); + + // Отдел принадлежит компании, поэтому список сужаем под выбранную: + // иначе пользователь листает ~700 позиций из всех юрлиц сразу. + const departmentsForSelectedCompany = React.useMemo( + () => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []), + [departments, form.companyId] + ); + const openCreate = (): void => { setEditing(undefined); setForm({ ...EMPTY_FORM, requestTypeId: filterRequestTypeId }); @@ -180,6 +215,7 @@ export const RoutesScreen: React.FC = ({ api, onOpenSteps })
+ {dictError && Справочник компаний и отделов недоступен: {dictError}} {loadError && {loadError}} {deleteError && {deleteError}} @@ -195,8 +231,8 @@ export const RoutesScreen: React.FC = ({ api, onOpenSteps }) header: 'Тип маршрута', render: r => {routeTypeLabels[r.routeType]} }, - { key: 'company', header: 'Компания', render: r => {shortGuid(r.companyId)} }, - { key: 'department', header: 'Отдел', render: r => (r.departmentId ? {shortGuid(r.departmentId)} : '—') }, + { key: 'company', header: 'Компания', render: r => companyName(r.companyId) }, + { key: 'department', header: 'Отдел', render: r => (r.departmentId ? departmentName(r.departmentId) : '—') }, { key: 'byProject', header: 'По проекту', @@ -260,18 +296,39 @@ export const RoutesScreen: React.FC = ({ api, onOpenSteps }) onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })} /> - - + ({ value: c.id, label: c.name }))} value={form.companyId} - onChange={e => setForm({ ...form, companyId: e.target.value })} - placeholder="00000000-0000-0000-0000-000000000000" + loading={dictLoading} + placeholder="Выберите компанию" + searchPlaceholder="Поиск по названию компании..." + onChange={companyId => + // Смена компании обнуляет отдел: прежний принадлежал другой + // компании, и бэкенд не нашёл бы такой маршрут при resolve. + setForm({ ...form, companyId, departmentId: '' }) + } /> - - + ({ value: d.id, label: d.name }))} value={form.departmentId} - onChange={e => setForm({ ...form, departmentId: e.target.value })} - placeholder="Оставьте пустым для маршрута по умолчанию" + loading={dictLoading} + disabled={!form.companyId} + allowClear + clearLabel="Без отдела (маршрут по умолчанию)" + placeholder="Без отдела (маршрут по умолчанию)" + searchPlaceholder="Поиск по названию отдела..." + onChange={departmentId => setForm({ ...form, departmentId })} /> diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss new file mode 100644 index 0000000..415bc98 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss @@ -0,0 +1,116 @@ +@import '../../../../styles/design-tokens'; + +.root { + position: relative; + width: 100%; +} + +.trigger { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; + width: 100%; + box-sizing: border-box; + padding: 11px 13px; + border: 1px solid $gray-300; + border-radius: $r-sm; + background: #fff; + font-family: $font-sans; + font-size: 13.5px; + color: $gray-900; + cursor: pointer; + text-align: left; + transition: .18s; + + @include input-focus-gold; + + &:disabled { background: $gray-50; color: $gray-400; cursor: not-allowed; } + &.open { border-color: $gold-500; box-shadow: 0 0 0 3px rgba(212,160,30,.12); } +} + +.value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.placeholder { color: $gray-400; } + +.chevron { color: $gray-400; flex-shrink: 0; } + +.menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: #fff; + border-radius: $r-md; + box-shadow: $sh-modal; + border: 1px solid $gray-200; + padding: 6px; + z-index: 400; + max-height: 320px; + display: flex; + flex-direction: column; +} + +.search { + padding: 9px 12px; + border: 0; + border-bottom: 1px solid $gray-200; + width: 100%; + box-sizing: border-box; + font-family: $font-sans; + font-size: 13.5px; + outline: 0; + margin-bottom: 6px; + flex-shrink: 0; + + &::placeholder { color: $gray-400; } +} + +.list { + overflow-y: auto; + flex: 1; +} + +.item { + padding: 9px 12px; + border-radius: $r-sm; + cursor: pointer; + font-size: 13.5px; + color: $gray-700; + transition: .1s; + display: block; + width: 100%; + box-sizing: border-box; + text-align: left; + border: 0; + background: transparent; + font-family: $font-sans; + + &:hover, &.active { background: $gold-50; color: $navy-900; } + &.selected { font-weight: 600; color: $navy-900; } +} + +.itemSub { + display: block; + font-size: 11px; + color: $gray-400; + margin-top: 2px; +} + +.empty { + padding: 16px 12px; + text-align: center; + color: $gray-400; + font-size: 12.5px; +} + +.clear { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid $gray-100; + flex-shrink: 0; +} diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx new file mode 100644 index 0000000..e476709 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import styles from './SearchableSelect.module.scss'; +import { Icon } from './Icon'; + +export interface ISearchableOption { + value: string; + label: string; + /** Второстепенная строка под названием — например, компания у отдела. */ + sublabel?: string; +} + +export interface ISearchableSelectProps { + options: ISearchableOption[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; + searchPlaceholder?: string; + disabled?: boolean; + loading?: boolean; + /** Показывать пункт «очистить» — для необязательных полей. */ + allowClear?: boolean; + clearLabel?: string; +} + +export const SearchableSelect: React.FC = ({ + options, + value, + onChange, + placeholder = 'Выберите значение', + searchPlaceholder = 'Поиск...', + disabled, + loading, + allowClear, + clearLabel = 'Не выбрано' +}) => { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(''); + const rootRef = React.useRef(null); + const searchRef = React.useRef(null); + + const selected = options.filter(o => o.value === value)[0]; + + // Поиск на клиенте: справочник целиком уже загружен, отделов ~700 — + // фильтровать в памяти дешевле, чем ходить на сервер на каждый символ. + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter(o => + o.label.toLowerCase().indexOf(q) !== -1 || + (o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false) + ); + }, [options, query]); + + React.useEffect(() => { + if (!open) return; + + const onDocumentMouseDown = (e: MouseEvent): void => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setOpen(false); + }; + + document.addEventListener('mousedown', onDocumentMouseDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onDocumentMouseDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + React.useEffect(() => { + if (open && searchRef.current) searchRef.current.focus(); + }, [open]); + + const toggle = (): void => { + if (disabled) return; + setQuery(''); + setOpen(!open); + }; + + const pick = (next: string): void => { + onChange(next); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+ setQuery(e.target.value)} + /> +
+ {filtered.length === 0 &&
Ничего не найдено
} + {filtered.map(opt => ( + + ))} +
+ {allowClear && value && ( +
+ +
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/index.ts b/frontend/src/webparts/approvalAdmin/components/ui/index.ts index febb4c4..5fc2494 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/index.ts +++ b/frontend/src/webparts/approvalAdmin/components/ui/index.ts @@ -7,3 +7,4 @@ export * from './Card'; export * from './DataTable'; export * from './Modal'; export * from './FormField'; +export * from './SearchableSelect'; diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index 5845e43..47d77e7 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -57,6 +57,21 @@ export const headTypeLabels: Record = { [HeadType.DirectorTansu]: 'Директор Tansu' }; +// ---- Справочники api.tnsu.kz, проксируемые нашим бэкендом ---- +// (api/dictionary/companies, api/dictionary/departments) + +export interface Company { + id: string; + name: string; +} + +export interface Department { + id: string; + name: string; + companyId: string; + company: string; +} + // ---- Request Types (api/request-types) ---- export interface RequestType { diff --git a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts new file mode 100644 index 0000000..e8c593b --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts @@ -0,0 +1,62 @@ +import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; +import { Company, Department } from '../models'; +import { ApiClientError } from './httpClient'; + +// Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API: +// в appsettings.json бэкенда Entra:Audience и TnsuPortal:ClientId — один и тот же +// GUID, то есть обе службы живут на одной регистрации Entra. Прокси не нужен. +// +// Два отличия от нашего API, из-за которых здесь отдельный клиент: +// 1) портал отдаёт «голый» массив, без конверта { data: ... }; +// 2) ключи в PascalCase (Id/Name/CompanyId/Company). + +interface IPortalCompany { + Id: string; + Name: string; +} + +interface IPortalDepartment { + Id: string; + Name: string; + CompanyId: string; + Company: string; +} + +async function getJson(client: AadHttpClient, baseUrl: string, path: string): Promise { + const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + const url = new URL(path.replace(/^\//, ''), normalizedBase).toString(); + + const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1); + const text = await response.text(); + + if (!response.ok) { + throw new ApiClientError(response.status, [ + `Справочник api.tnsu.kz вернул HTTP ${response.status}${text ? `: ${text}` : ''}` + ]); + } + + try { + return JSON.parse(text) as T; + } catch { + throw new ApiClientError(response.status, ['Справочник api.tnsu.kz вернул не-JSON ответ.']); + } +} + +export class PortalDictionaryClient { + constructor(private readonly client: AadHttpClient, private readonly baseUrl: string) {} + + public async companies(): Promise { + const raw = await getJson(this.client, this.baseUrl, '/Dictionary/Companies'); + return (raw ?? []).map(x => ({ id: x.Id, name: x.Name })); + } + + public async departments(): Promise { + const raw = await getJson(this.client, this.baseUrl, '/Dictionary/Departments'); + return (raw ?? []).map(x => ({ + id: x.Id, + name: x.Name, + companyId: x.CompanyId, + company: x.Company + })); + } +}