Фронтенд для сервиса #5
@@ -50,7 +50,9 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
|
|||||||
onBack={() => setScreen('routes')}
|
onBack={() => setScreen('routes')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{screen === 'resolve' && <ResolveScreen api={api} currentUserEmail={currentUserEmail} />}
|
{screen === 'resolve' && (
|
||||||
|
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { ApiClientError } from '../../services/httpClient';
|
import { ApiClientError } from '../../services/httpClient';
|
||||||
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import {
|
import {
|
||||||
|
Project,
|
||||||
RequestType,
|
RequestType,
|
||||||
ResolveTemplateRouteResult,
|
ResolveTemplateRouteResult,
|
||||||
routeStepStatusLabels,
|
routeStepStatusLabels,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
} from '../../models';
|
} from '../../models';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
AsyncSearchableSelect,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
DataTable,
|
DataTable,
|
||||||
@@ -23,17 +26,20 @@ import {
|
|||||||
|
|
||||||
export interface IResolveScreenProps {
|
export interface IResolveScreenProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||||
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||||||
|
|
||||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, currentUserEmail }) => {
|
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||||
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
||||||
const [requestTypeId, setRequestTypeId] = React.useState('');
|
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||||
const [userEmail, setUserEmail] = React.useState('');
|
const [userEmail, setUserEmail] = React.useState('');
|
||||||
const [projectId, setProjectId] = React.useState('');
|
const [projectId, setProjectId] = React.useState('');
|
||||||
|
const [projectLabel, setProjectLabel] = React.useState('');
|
||||||
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
|
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
|
||||||
|
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
@@ -44,6 +50,16 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, currentUserE
|
|||||||
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается при попытке проверки */ });
|
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается при попытке проверки */ });
|
||||||
}, [api]);
|
}, [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 => {
|
const run = (): void => {
|
||||||
if (!requestTypeId) {
|
if (!requestTypeId) {
|
||||||
setError('Выберите тип заявки.');
|
setError('Выберите тип заявки.');
|
||||||
@@ -86,8 +102,24 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, currentUserE
|
|||||||
<Field label="Email инициатора" hint={currentUserEmail ? `Пусто = текущий пользователь (${currentUserEmail}).` : 'Пусто = текущий пользователь из токена.'}>
|
<Field label="Email инициатора" hint={currentUserEmail ? `Пусто = текущий пользователь (${currentUserEmail}).` : 'Пусто = текущий пользователь из токена.'}>
|
||||||
<TextInput value={userEmail} onChange={e => setUserEmail(e.target.value)} placeholder="user@tnsu.kz" />
|
<TextInput value={userEmail} onChange={e => setUserEmail(e.target.value)} placeholder="user@tnsu.kz" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Id проекта (Guid)" hint="Заполните, если маршрут зависит от проекта.">
|
<Field label="Проект" hint="Заполните, если маршрут зависит от проекта (ByProject = true).">
|
||||||
<TextInput value={projectId} onChange={e => setProjectId(e.target.value)} placeholder="Необязательно" />
|
<AsyncSearchableSelect<Project>
|
||||||
|
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 : '');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Статус" required>
|
<Field label="Статус" required>
|
||||||
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './SearchableSelect.module.scss';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
export interface IAsyncSearchResult<T> {
|
||||||
|
items: T[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAsyncSearchableSelectProps<T> {
|
||||||
|
value: string;
|
||||||
|
/** Название выбранного значения — показываем в триггере без похода на сервер. */
|
||||||
|
selectedLabel?: string;
|
||||||
|
onChange: (value: string, item: T | undefined) => void;
|
||||||
|
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
|
||||||
|
getValue: (item: T) => string;
|
||||||
|
getLabel: (item: T) => string;
|
||||||
|
getSublabel?: (item: T) => string | undefined;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
allowClear?: boolean;
|
||||||
|
clearLabel?: string;
|
||||||
|
/** Задержка перед запросом после ввода — не долбим портал на каждый символ. */
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AsyncSearchableSelect<T>({
|
||||||
|
value,
|
||||||
|
selectedLabel,
|
||||||
|
onChange,
|
||||||
|
search,
|
||||||
|
getValue,
|
||||||
|
getLabel,
|
||||||
|
getSublabel,
|
||||||
|
placeholder = 'Выберите значение',
|
||||||
|
searchPlaceholder = 'Поиск...',
|
||||||
|
disabled,
|
||||||
|
allowClear,
|
||||||
|
clearLabel = 'Не выбрано',
|
||||||
|
debounceMs = 350
|
||||||
|
}: IAsyncSearchableSelectProps<T>): React.ReactElement {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [items, setItems] = React.useState<T[]>([]);
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [hasMore, setHasMore] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
// Растущий счётчик запросов: если ответ пришёл не от последнего запроса
|
||||||
|
// (например, пользователь допечатал ещё символ) — просто игнорируем его.
|
||||||
|
const requestIdRef = React.useRef(0);
|
||||||
|
|
||||||
|
const runSearch = React.useCallback((q: string, targetPage: number, append: boolean) => {
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
if (append) setLoadingMore(true); else setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
search(q, targetPage)
|
||||||
|
.then(result => {
|
||||||
|
if (requestIdRef.current !== requestId) return; // устарел
|
||||||
|
setItems(prev => (append ? [...prev, ...result.items] : result.items));
|
||||||
|
setHasMore(result.hasMore);
|
||||||
|
setPage(targetPage);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
});
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Открытие — сразу показываем первую страницу (пустой запрос = «последние/все»),
|
||||||
|
// без debounce. Эффект ниже (deps: [query]) на само открытие не реагирует —
|
||||||
|
// toggle() всегда сбрасывает query в '', так что при чистом открытии/закрытии
|
||||||
|
// без ввода текста он не перезапускается и второго запроса не улетает.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
runSearch('', 1, false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Debounce поиска по вводу.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handle = setTimeout(() => runSearch(query, 1, false), debounceMs);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [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 = (item: T | undefined): void => {
|
||||||
|
onChange(item ? getValue(item) : '', item);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.trigger} ${open ? styles.open : ''}`}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
||||||
|
{value ? (selectedLabel ?? value) : placeholder}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={styles.menu}>
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
className={styles.search}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{loading && <div className={styles.empty}>Поиск...</div>}
|
||||||
|
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
||||||
|
{!loading && !error && items.length === 0 && (
|
||||||
|
<div className={styles.empty}>Ничего не найдено</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && items.map(item => {
|
||||||
|
const itemValue = getValue(item);
|
||||||
|
const sub = getSublabel ? getSublabel(item) : undefined;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={itemValue}
|
||||||
|
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
||||||
|
onClick={() => pick(item)}
|
||||||
|
>
|
||||||
|
{getLabel(item)}
|
||||||
|
{sub && <span className={styles.itemSub}>{sub}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading && !error && hasMore && (
|
||||||
|
<div className={styles.loadMoreRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.loadMoreBtn}
|
||||||
|
disabled={loadingMore}
|
||||||
|
onClick={() => runSearch(query, page + 1, true)}
|
||||||
|
>
|
||||||
|
{loadingMore ? 'Загрузка...' : 'Показать ещё'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{allowClear && value && (
|
||||||
|
<div className={styles.clear}>
|
||||||
|
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,6 +108,34 @@
|
|||||||
font-size: 12.5px;
|
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 {
|
.clear {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from './DataTable';
|
|||||||
export * from './Modal';
|
export * from './Modal';
|
||||||
export * from './FormField';
|
export * from './FormField';
|
||||||
export * from './SearchableSelect';
|
export * from './SearchableSelect';
|
||||||
|
export * from './AsyncSearchableSelect';
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ export interface Department {
|
|||||||
company: string;
|
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) ----
|
// ---- Request Types (api/request-types) ----
|
||||||
|
|
||||||
export interface RequestType {
|
export interface RequestType {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
import { Company, Department } from '../models';
|
import { Company, Department, Project } from '../models';
|
||||||
import { ApiClientError } from './httpClient';
|
import { ApiClientError } from './httpClient';
|
||||||
|
|
||||||
// Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API:
|
// Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API:
|
||||||
@@ -22,6 +22,29 @@ interface IPortalDepartment {
|
|||||||
Company: string;
|
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<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
||||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||||
const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
|
const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
|
||||||
@@ -59,4 +82,15 @@ export class PortalDictionaryClient {
|
|||||||
company: x.Company
|
company: x.Company
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async searchProjects(name: string, page: number, pageSize = 20): Promise<IProjectSearchResult> {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
|
if (name.trim()) params.set('name', name.trim());
|
||||||
|
|
||||||
|
const raw = await getJson<IPortalProjectPage>(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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user