Фронтенд для сервиса #5
@@ -25,7 +25,8 @@
|
|||||||
"apiBaseUrl": "http://localhost:5080",
|
"apiBaseUrl": "http://localhost:5080",
|
||||||
"useDevAuth": true,
|
"useDevAuth": true,
|
||||||
"devUserEmail": "dev@tnsu.kz",
|
"devUserEmail": "dev@tnsu.kz",
|
||||||
"aadResourceId": "api://9232d9e2-9807-4753-808f-07250e5474b0"
|
"aadResourceId": "api://9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"portalBaseUrl": "https://api.tnsu.kz"
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface IApprovalAdminWebPartProps {
|
|||||||
useDevAuth: boolean;
|
useDevAuth: boolean;
|
||||||
devUserEmail: string;
|
devUserEmail: string;
|
||||||
aadResourceId: string;
|
aadResourceId: string;
|
||||||
|
portalBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprovalAdminWebPartProps> {
|
export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprovalAdminWebPartProps> {
|
||||||
@@ -26,7 +27,8 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
|||||||
apiBaseUrl: this.properties.apiBaseUrl,
|
apiBaseUrl: this.properties.apiBaseUrl,
|
||||||
useDevAuth: this.properties.useDevAuth,
|
useDevAuth: this.properties.useDevAuth,
|
||||||
devUserEmail: this.properties.devUserEmail,
|
devUserEmail: this.properties.devUserEmail,
|
||||||
aadResourceId: this.properties.aadResourceId
|
aadResourceId: this.properties.aadResourceId,
|
||||||
|
portalBaseUrl: this.properties.portalBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
ReactDom.render(element, this.domElement);
|
ReactDom.render(element, this.domElement);
|
||||||
@@ -37,6 +39,7 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
|||||||
this.properties.useDevAuth = this.properties.useDevAuth ?? true;
|
this.properties.useDevAuth = this.properties.useDevAuth ?? true;
|
||||||
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
|
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
|
||||||
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
|
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
|
||||||
|
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +77,11 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
|||||||
}),
|
}),
|
||||||
PropertyPaneTextField('aadResourceId', {
|
PropertyPaneTextField('aadResourceId', {
|
||||||
label: 'AAD Resource Id (App ID URI)',
|
label: 'AAD Resource Id (App ID URI)',
|
||||||
description: 'Используется только при выключенном Dev-режиме. Должен совпадать с webApiPermissionRequests в package-solution.json.'
|
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
|
||||||
|
}),
|
||||||
|
PropertyPaneTextField('portalBaseUrl', {
|
||||||
|
label: 'Портал справочников (api.tnsu.kz)',
|
||||||
|
description: 'Откуда берутся списки компаний и отделов. Ходим напрямую, тем же AAD-токеном.'
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { IApprovalAdminProps } from './IApprovalAdminProps';
|
|||||||
import { AdminApp } from './app/AdminApp';
|
import { AdminApp } from './app/AdminApp';
|
||||||
import { ApprovalRouteApi } from '../services/api';
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||||
|
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||||
|
|
||||||
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient
|
|||||||
|
|
||||||
const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
||||||
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
|
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
|
||||||
|
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
|
||||||
const [error, setError] = React.useState<string | undefined>();
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -45,6 +47,24 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]);
|
}, [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 (
|
return (
|
||||||
<section className={styles.approvalAdmin}>
|
<section className={styles.approvalAdmin}>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -55,7 +75,13 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!error && !api && <div className={styles.stateBox}>Подключение к API...</div>}
|
{!error && !api && <div className={styles.stateBox}>Подключение к API...</div>}
|
||||||
{api && <AdminApp api={api} currentUserEmail={props.context.pageContext.user.email} />}
|
{api && (
|
||||||
|
<AdminApp
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
currentUserEmail={props.context.pageContext.user.email}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export interface IApprovalAdminProps {
|
|||||||
useDevAuth: boolean;
|
useDevAuth: boolean;
|
||||||
devUserEmail: string;
|
devUserEmail: string;
|
||||||
aadResourceId: string;
|
aadResourceId: string;
|
||||||
|
portalBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import styles from './AdminApp.module.scss';
|
|||||||
import { TopNav } from './TopNav';
|
import { TopNav } from './TopNav';
|
||||||
import { ScreenKey } from './types';
|
import { ScreenKey } from './types';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { TemplateRoute } from '../../models';
|
import { TemplateRoute } from '../../models';
|
||||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||||
@@ -12,10 +13,12 @@ import { ResolveScreen } from '../screens/ResolveScreen';
|
|||||||
|
|
||||||
export interface IAdminAppProps {
|
export interface IAdminAppProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdminApp: React.FC<IAdminAppProps> = ({ api, currentUserEmail }) => {
|
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||||
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
|
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
|
||||||
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||||
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState('');
|
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState('');
|
||||||
@@ -38,7 +41,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, currentUserEmail }) =>
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
|
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
|
||||||
{screen === 'requestTypes' && <RequestTypesScreen api={api} />}
|
{screen === 'requestTypes' && <RequestTypesScreen api={api} />}
|
||||||
{screen === 'routes' && <RoutesScreen api={api} onOpenSteps={openSteps} />}
|
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
|
||||||
{screen === 'routeSteps' && selectedRoute && (
|
{screen === 'routeSteps' && selectedRoute && (
|
||||||
<RouteStepsScreen
|
<RouteStepsScreen
|
||||||
api={api}
|
api={api}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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 { RequestType, routeTypeLabels, RouteType, TemplateRoute } from '../../models';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
|
import { Company, Department, RequestType, routeTypeLabels, RouteType, TemplateRoute } from '../../models';
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -17,12 +18,13 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PrimaryCell,
|
PrimaryCell,
|
||||||
Select,
|
SearchableSelect,
|
||||||
TextInput
|
Select
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
|
|
||||||
export interface IRoutesScreenProps {
|
export interface IRoutesScreenProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
onOpenSteps: (route: TemplateRoute) => void;
|
onOpenSteps: (route: TemplateRoute) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +50,12 @@ const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
|
|||||||
.filter(v => v !== RouteType.None)
|
.filter(v => v !== RouteType.None)
|
||||||
.map(v => ({ value: v, label: routeTypeLabels[v] }));
|
.map(v => ({ value: v, label: routeTypeLabels[v] }));
|
||||||
|
|
||||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, onOpenSteps }) => {
|
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
||||||
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
||||||
|
const [companies, setCompanies] = React.useState<Company[]>([]);
|
||||||
|
const [departments, setDepartments] = React.useState<Department[]>([]);
|
||||||
|
const [dictLoading, setDictLoading] = React.useState(true);
|
||||||
|
const [dictError, setDictError] = React.useState<string | undefined>();
|
||||||
const [items, setItems] = React.useState<TemplateRoute[]>([]);
|
const [items, setItems] = React.useState<TemplateRoute[]>([]);
|
||||||
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState<string>('');
|
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState<string>('');
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
@@ -69,6 +75,22 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, onOpenSteps })
|
|||||||
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается в общей ошибке ниже */ });
|
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается в общей ошибке ниже */ });
|
||||||
}, [api]);
|
}, [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(() => {
|
const load = React.useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError(undefined);
|
setLoadError(undefined);
|
||||||
@@ -84,6 +106,19 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, onOpenSteps })
|
|||||||
|
|
||||||
const requestTypeName = (id: number): string => requestTypes.find(rt => rt.id === id)?.name ?? `#${id}`;
|
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 => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setForm({ ...EMPTY_FORM, requestTypeId: filterRequestTypeId });
|
setForm({ ...EMPTY_FORM, requestTypeId: filterRequestTypeId });
|
||||||
@@ -180,6 +215,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, onOpenSteps })
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{dictError && <Alert kind="warn">Справочник компаний и отделов недоступен: {dictError}</Alert>}
|
||||||
{loadError && <Alert kind="err">{loadError}</Alert>}
|
{loadError && <Alert kind="err">{loadError}</Alert>}
|
||||||
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
||||||
|
|
||||||
@@ -195,8 +231,8 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, onOpenSteps })
|
|||||||
header: 'Тип маршрута',
|
header: 'Тип маршрута',
|
||||||
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
|
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
|
||||||
},
|
},
|
||||||
{ key: 'company', header: 'Компания', render: r => <code>{shortGuid(r.companyId)}</code> },
|
{ key: 'company', header: 'Компания', render: r => companyName(r.companyId) },
|
||||||
{ key: 'department', header: 'Отдел', render: r => (r.departmentId ? <code>{shortGuid(r.departmentId)}</code> : '—') },
|
{ key: 'department', header: 'Отдел', render: r => (r.departmentId ? departmentName(r.departmentId) : '—') },
|
||||||
{
|
{
|
||||||
key: 'byProject',
|
key: 'byProject',
|
||||||
header: 'По проекту',
|
header: 'По проекту',
|
||||||
@@ -260,18 +296,39 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, onOpenSteps })
|
|||||||
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
|
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Компания (Guid)" required full hint="Из справочника api.tnsu.kz.">
|
<Field label="Компания" required full hint="Справочник 1С (api.tnsu.kz).">
|
||||||
<TextInput
|
<SearchableSelect
|
||||||
|
options={companies.map(c => ({ value: c.id, label: c.name }))}
|
||||||
value={form.companyId}
|
value={form.companyId}
|
||||||
onChange={e => setForm({ ...form, companyId: e.target.value })}
|
loading={dictLoading}
|
||||||
placeholder="00000000-0000-0000-0000-000000000000"
|
placeholder="Выберите компанию"
|
||||||
|
searchPlaceholder="Поиск по названию компании..."
|
||||||
|
onChange={companyId =>
|
||||||
|
// Смена компании обнуляет отдел: прежний принадлежал другой
|
||||||
|
// компании, и бэкенд не нашёл бы такой маршрут при resolve.
|
||||||
|
setForm({ ...form, companyId, departmentId: '' })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Отдел (Guid)" full hint="Необязательно — маршрут «по умолчанию» для компании.">
|
<Field
|
||||||
<TextInput
|
label="Отдел"
|
||||||
|
full
|
||||||
|
hint={
|
||||||
|
form.companyId
|
||||||
|
? 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.'
|
||||||
|
: 'Сначала выберите компанию.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchableSelect
|
||||||
|
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
|
||||||
value={form.departmentId}
|
value={form.departmentId}
|
||||||
onChange={e => setForm({ ...form, departmentId: e.target.value })}
|
loading={dictLoading}
|
||||||
placeholder="Оставьте пустым для маршрута по умолчанию"
|
disabled={!form.companyId}
|
||||||
|
allowClear
|
||||||
|
clearLabel="Без отдела (маршрут по умолчанию)"
|
||||||
|
placeholder="Без отдела (маршрут по умолчанию)"
|
||||||
|
searchPlaceholder="Поиск по названию отдела..."
|
||||||
|
onChange={departmentId => setForm({ ...form, departmentId })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="По проекту">
|
<Field label="По проекту">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<ISearchableSelectProps> = ({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Выберите значение',
|
||||||
|
searchPlaceholder = 'Поиск...',
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
allowClear,
|
||||||
|
clearLabel = 'Не выбрано'
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const searchRef = React.useRef<HTMLInputElement>(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 (
|
||||||
|
<div className={styles.root} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.trigger} ${open ? styles.open : ''}`}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
<span className={`${styles.value} ${selected ? '' : styles.placeholder}`}>
|
||||||
|
{loading ? 'Загрузка справочника...' : (selected ? selected.label : 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}>
|
||||||
|
{filtered.length === 0 && <div className={styles.empty}>Ничего не найдено</div>}
|
||||||
|
{filtered.map(opt => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={opt.value}
|
||||||
|
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||||
|
onClick={() => pick(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{allowClear && value && (
|
||||||
|
<div className={styles.clear}>
|
||||||
|
<button type="button" className={styles.item} onClick={() => pick('')}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,3 +7,4 @@ export * from './Card';
|
|||||||
export * from './DataTable';
|
export * from './DataTable';
|
||||||
export * from './Modal';
|
export * from './Modal';
|
||||||
export * from './FormField';
|
export * from './FormField';
|
||||||
|
export * from './SearchableSelect';
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ export const headTypeLabels: Record<HeadType, string> = {
|
|||||||
[HeadType.DirectorTansu]: 'Директор Tansu'
|
[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) ----
|
// ---- Request Types (api/request-types) ----
|
||||||
|
|
||||||
export interface RequestType {
|
export interface RequestType {
|
||||||
|
|||||||
@@ -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<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
||||||
|
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<Company[]> {
|
||||||
|
const raw = await getJson<IPortalCompany[]>(this.client, this.baseUrl, '/Dictionary/Companies');
|
||||||
|
return (raw ?? []).map(x => ({ id: x.Id, name: x.Name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async departments(): Promise<Department[]> {
|
||||||
|
const raw = await getJson<IPortalDepartment[]>(this.client, this.baseUrl, '/Dictionary/Departments');
|
||||||
|
return (raw ?? []).map(x => ({
|
||||||
|
id: x.Id,
|
||||||
|
name: x.Name,
|
||||||
|
companyId: x.CompanyId,
|
||||||
|
company: x.Company
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user