diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 0af5664..857e417 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -50,7 +50,9 @@ export const AdminApp: React.FC = ({ api, dictionary, currentUse onBack={() => setScreen('routes')} /> )} - {screen === 'resolve' && } + {screen === 'resolve' && ( + + )} ); diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx index bda836d..bfbd0d3 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; import { ApprovalRouteApi } from '../../services/api'; import { ApiClientError } from '../../services/httpClient'; +import { PortalDictionaryClient } from '../../services/portalDictionary'; import { + Project, RequestType, ResolveTemplateRouteResult, routeStepStatusLabels, @@ -10,6 +12,7 @@ import { } from '../../models'; import { Alert, + AsyncSearchableSelect, Breadcrumbs, Button, DataTable, @@ -23,17 +26,20 @@ import { export interface IResolveScreenProps { api: ApprovalRouteApi; + /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ + dictionary?: PortalDictionaryClient; currentUserEmail?: string; } const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] .map(v => ({ value: v, label: routeStepStatusLabels[v] })); -export const ResolveScreen: React.FC = ({ api, currentUserEmail }) => { +export const ResolveScreen: React.FC = ({ api, dictionary, currentUserEmail }) => { const [requestTypes, setRequestTypes] = React.useState([]); const [requestTypeId, setRequestTypeId] = React.useState(''); const [userEmail, setUserEmail] = React.useState(''); const [projectId, setProjectId] = React.useState(''); + const [projectLabel, setProjectLabel] = React.useState(''); const [status, setStatus] = React.useState(RouteStepStatus.Approval); const [busy, setBusy] = React.useState(false); @@ -44,6 +50,16 @@ export const ResolveScreen: React.FC = ({ api, currentUserE api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается при попытке проверки */ }); }, [api]); + const searchProjects = React.useCallback( + (query: string, page: number) => { + if (!dictionary) { + return Promise.reject(new Error('Справочник проектов ещё загружается.')); + } + return dictionary.searchProjects(query, page); + }, + [dictionary] + ); + const run = (): void => { if (!requestTypeId) { setError('Выберите тип заявки.'); @@ -86,8 +102,24 @@ export const ResolveScreen: React.FC = ({ api, currentUserE setUserEmail(e.target.value)} placeholder="user@tnsu.kz" /> - - setProjectId(e.target.value)} placeholder="Необязательно" /> + + + value={projectId} + selectedLabel={projectLabel} + disabled={!dictionary} + allowClear + clearLabel="Без проекта" + placeholder={dictionary ? 'Не выбран' : 'Справочник загружается...'} + searchPlaceholder="Поиск проекта по названию..." + search={searchProjects} + getValue={p => p.oid} + getLabel={p => p.name} + getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined} + onChange={(nextId, item) => { + setProjectId(nextId); + setProjectLabel(item ? item.name : ''); + }} + /> setQuery(e.target.value)} + /> +
+ {loading &&
Поиск...
} + {!loading && error &&
{error}
} + {!loading && !error && items.length === 0 && ( +
Ничего не найдено
+ )} + {!loading && !error && items.map(item => { + const itemValue = getValue(item); + const sub = getSublabel ? getSublabel(item) : undefined; + return ( + + ); + })} + {!loading && !error && hasMore && ( +
+ +
+ )} +
+ {allowClear && value && ( +
+ +
+ )} + + )} + + ); +} diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss index 415bc98..a43e718 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss @@ -108,6 +108,34 @@ font-size: 12.5px; } +.errorText { + padding: 10px 12px; + color: $red-500; + font-size: 12px; + text-align: center; +} + +.loadMoreRow { + padding: 4px 12px 2px; + flex-shrink: 0; +} + +.loadMoreBtn { + width: 100%; + padding: 8px 12px; + border-radius: $r-sm; + border: 1px dashed $gray-300; + background: transparent; + color: $navy-700; + font-family: $font-sans; + font-size: 12.5px; + cursor: pointer; + transition: .15s; + + &:hover:not(:disabled) { border-color: $gold-500; color: $gold-600; background: $gold-50; } + &:disabled { opacity: .5; cursor: not-allowed; } +} + .clear { margin-top: 6px; padding-top: 6px; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/index.ts b/frontend/src/webparts/approvalAdmin/components/ui/index.ts index 5fc2494..7974472 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/index.ts +++ b/frontend/src/webparts/approvalAdmin/components/ui/index.ts @@ -8,3 +8,4 @@ export * from './DataTable'; export * from './Modal'; export * from './FormField'; export * from './SearchableSelect'; +export * from './AsyncSearchableSelect'; diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index 47d77e7..196f37b 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -72,6 +72,17 @@ export interface Department { company: string; } +// api.tnsu.kz/Project — важно: используем поле "oid" как идентификатор проекта +// (это то же самое значение, которое /Project/{oid:guid} на портале принимает +// и которое передаётся как ResolveTemplateRouteRequest.ProjectId). Числовое +// "id" и legacy "id1c" — другие идентификаторы, для резолва не подходят. +export interface Project { + oid: string; + name: string; + code: string | null; + status: string | null; +} + // ---- 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 index e8c593b..55afd08 100644 --- a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts +++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts @@ -1,5 +1,5 @@ import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; -import { Company, Department } from '../models'; +import { Company, Department, Project } from '../models'; import { ApiClientError } from './httpClient'; // Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API: @@ -22,6 +22,29 @@ interface IPortalDepartment { Company: string; } +// GET /Project?name=&page=&pageSize= — в отличие от /Dictionary/*, здесь портал +// уже отдаёт camelCase и постраничный конверт (данные идут из своей БД, а не +// из сырых 1С-справочников). +interface IPortalProject { + oid: string; + name: string; + code: string | null; + status: string | null; +} + +interface IPortalProjectPage { + items: IPortalProject[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface IProjectSearchResult { + items: Project[]; + hasMore: boolean; +} + async function getJson(client: AadHttpClient, baseUrl: string, path: string): Promise { const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const url = new URL(path.replace(/^\//, ''), normalizedBase).toString(); @@ -59,4 +82,15 @@ export class PortalDictionaryClient { company: x.Company })); } + + public async searchProjects(name: string, page: number, pageSize = 20): Promise { + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); + if (name.trim()) params.set('name', name.trim()); + + const raw = await getJson(this.client, this.baseUrl, `/Project?${params.toString()}`); + return { + items: (raw.items ?? []).map(x => ({ oid: x.oid, name: x.name, code: x.code, status: x.status })), + hasMore: raw.page < raw.totalPages + }; + } }