Фронтенд для сервиса (#5)
Tests / test (push) Successful in 2m26s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
2026-07-30 06:11:24 +00:00
parent 5476c1bab5
commit da158cfd84
114 changed files with 26045 additions and 2 deletions
@@ -0,0 +1,32 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "4825e72c-4ff3-479e-917e-f6b04e003a82",
"alias": "ApprovalAdminWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
"supportsThemeVariants": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
"group": { "default": "Advanced" },
"title": { "default": "approval-admin" },
"description": { "default": "approval-admin description" },
"officeFabricIconFontName": "Page",
"properties": {
"apiBaseUrl": "https://api.tnsu.kz/approval-route",
"useDevAuth": false,
"devUserEmail": "dev@tnsu.kz",
"aadResourceId": "api://9232d9e2-9807-4753-808f-07250e5474b0",
"portalBaseUrl": "https://api.tnsu.kz"
}
}]
}
@@ -0,0 +1,99 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
type IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneToggle
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import ApprovalAdmin from './components/ApprovalAdmin';
import { IApprovalAdminProps } from './components/IApprovalAdminProps';
export interface IApprovalAdminWebPartProps {
apiBaseUrl: string;
useDevAuth: boolean;
devUserEmail: string;
aadResourceId: string;
portalBaseUrl: string;
}
export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprovalAdminWebPartProps> {
public render(): void {
const element: React.ReactElement<IApprovalAdminProps> = React.createElement(ApprovalAdmin, {
context: this.context,
apiBaseUrl: this.properties.apiBaseUrl,
useDevAuth: this.properties.useDevAuth,
devUserEmail: this.properties.devUserEmail,
aadResourceId: this.properties.aadResourceId,
portalBaseUrl: this.properties.portalBaseUrl
});
ReactDom.render(element, this.domElement);
}
protected onInit(): Promise<void> {
// Прод-адрес по умолчанию: пакуем именно под прод (heft package-solution --production).
// Для локальной разработки переопределите значение в панели свойств веб-части
// (например, на http://localhost:5080 — порт из backend/.../launchSettings.json).
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route';
// Dev-режим ломает /resolve: наш бэкенд не получает Bearer-токен пользователя
// для пересылки в api.tnsu.kz и падает на собственном client_credentials
// (TnsuPortal:ClientSecret в appsettings.json пуст). По умолчанию — реальный AAD.
this.properties.useDevAuth = this.properties.useDevAuth ?? false;
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
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();
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: 'Подключение к Tnsu.ApprovalRoute.Api'
},
groups: [
{
groupName: 'API',
groupFields: [
PropertyPaneTextField('apiBaseUrl', {
label: 'Api Base Url',
description: 'По умолчанию — прод (https://api.tnsu.kz/approval-route). Для локальной разработки укажите http://localhost:5080.'
}),
PropertyPaneToggle('useDevAuth', {
label: 'Локальный Dev-режим (без Azure AD)',
onText: 'Вкл. (заголовок X-Dev-User-Email)',
offText: 'Выкл. (AAD-токен через SPFx)'
}),
PropertyPaneTextField('devUserEmail', {
label: 'Dev: email пользователя',
description: 'Используется только при включённом Dev-режиме.'
}),
PropertyPaneTextField('aadResourceId', {
label: 'AAD Resource Id (App ID URI)',
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
}),
PropertyPaneTextField('portalBaseUrl', {
label: 'Портал справочников (api.tnsu.kz)',
description: 'Откуда берутся списки компаний и отделов. Ходим напрямую, тем же AAD-токеном.'
})
]
}
]
}
]
};
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,15 @@
@import '../../../styles/design-tokens';
.approvalAdmin {
font-family: $font-sans;
}
.stateBox {
padding: 40px 24px;
text-align: center;
color: $gray-500;
font-size: 13.5px;
background: $gray-50;
border-radius: $r-lg;
border: 1px solid $gray-200;
}
@@ -0,0 +1,90 @@
import * as React from 'react';
import styles from './ApprovalAdmin.module.scss';
import type { IApprovalAdminProps } from './IApprovalAdminProps';
import { AdminApp } from './app/AdminApp';
import { ApprovalRouteApi } from '../services/api';
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary';
import { texts } from '../texts';
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
function ensureFontsLoaded(): void {
if (typeof document === 'undefined' || document.getElementById(FONTS_LINK_ID)) return;
const link = document.createElement('link');
link.id = FONTS_LINK_ID;
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&display=swap';
document.head.appendChild(link);
}
async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient> {
if (props.useDevAuth) {
return new DevApiHttpClient(props.apiBaseUrl, props.devUserEmail || 'dev@tnsu.kz');
}
const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId);
return new AadApiHttpClient(aadClient, props.apiBaseUrl);
}
const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
ensureFontsLoaded();
}, []);
React.useEffect(() => {
let cancelled = false;
setApi(undefined);
setError(undefined);
createHttpClient(props)
.then(client => { if (!cancelled) setApi(new ApprovalRouteApi(client)); })
.catch((e: Error) => { if (!cancelled) setError(e.message); });
return () => { cancelled = true; };
// 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 (
<section className={styles.approvalAdmin}>
{error && (
<div className={styles.stateBox}>
{texts.app.connectFailed(error)}
<br />
{texts.app.connectHint}
</div>
)}
{!error && !api && <div className={styles.stateBox}>{texts.app.connecting}</div>}
{api && (
<AdminApp
api={api}
dictionary={dictionary}
currentUserEmail={props.context.pageContext.user.email}
/>
)}
</section>
);
};
export default ApprovalAdmin;
@@ -0,0 +1,10 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface IApprovalAdminProps {
context: WebPartContext;
apiBaseUrl: string;
useDevAuth: boolean;
devUserEmail: string;
aadResourceId: string;
portalBaseUrl: string;
}
@@ -0,0 +1,26 @@
@import '../../../../styles/design-tokens';
.shell {
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
border-radius: $r-lg;
border: 1px solid $gray-200;
overflow: hidden;
box-shadow: $sh-1;
color: $gray-900;
font-family: $font-sans;
font-size: $fs-body;
line-height: 1.55;
}
.content {
padding: 28px 30px 36px;
max-width: 1080px;
margin: 0 auto;
}
.centerState {
padding: 60px 30px;
text-align: center;
color: $gray-400;
font-size: 13.5px;
}
@@ -0,0 +1,60 @@
import * as React from 'react';
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';
import { RoutesScreen } from '../screens/RoutesScreen';
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
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<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => {
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState('');
const navigate = (key: ScreenKey): void => {
setScreen(key);
};
const openSteps = (route: TemplateRoute): void => {
setSelectedRoute(route);
api.requestTypes.get(route.requestTypeId)
.then(rt => setSelectedRequestTypeName(rt.name))
.catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`));
setScreen('routeSteps');
};
return (
<div className={styles.shell}>
<TopNav active={screen} onSelect={navigate} />
<div className={styles.content}>
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
{screen === 'requestTypes' && <RequestTypesScreen api={api} />}
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
{screen === 'routeSteps' && selectedRoute && (
<RouteStepsScreen
api={api}
route={selectedRoute}
requestTypeName={selectedRequestTypeName}
dictionary={dictionary}
onBack={() => setScreen('routes')}
/>
)}
{screen === 'resolve' && (
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} />
)}
</div>
</div>
);
};
@@ -0,0 +1,87 @@
@import '../../../../styles/design-tokens';
.topnav {
background: $grad-header;
padding: 0 22px;
display: flex;
align-items: center;
height: 54px;
box-shadow: 0 1px 0 rgba(212,160,30,.35);
border-radius: $r-md $r-md 0 0;
// Без переноса: высота шапки фиксирована (54px), поэтому перенесённая
// строка вкладок вылезала бы за её пределы вместе с подчёркиванием.
// Вместо переноса вкладки скроллятся по горизонтали.
flex-wrap: nowrap;
overflow: hidden;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
margin-right: 28px;
flex-shrink: 0;
}
.mark {
width: 30px;
height: 30px;
border-radius: 7px;
background: $grad-gold-v;
display: flex;
align-items: center;
justify-content: center;
color: $navy-950;
font-weight: 800;
font-size: 13px;
font-family: $font-serif;
}
.brandText {
color: #fff;
font-weight: 600;
font-size: 13px;
letter-spacing: .4px;
white-space: nowrap;
}
.tabs {
display: flex;
height: 100%;
flex-wrap: nowrap;
// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего
// содержимого и overflow-x никогда не сработает.
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
// Делает контейнер offsetParent'ом для вкладок: автопрокрутка в TopNav.tsx
// считает offsetLeft относительно него. Без этого offsetLeft отсчитывался
// бы от body и прокрутка уезжала бы не туда.
position: relative;
// Полоса прокрутки на тёмной шапке выглядит инородно, скрываем её —
// вкладки остаются доступны свайпом/колесом.
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.tab {
flex-shrink: 0;
white-space: nowrap;
padding: 0 16px;
border: none;
background: 0;
color: rgba(255,255,255,.5);
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: .18s;
height: 54px;
border-bottom: 2px solid transparent;
letter-spacing: .2px;
&:hover { color: rgba(255,255,255,.85); }
&.on { color: $gold-300; border-bottom-color: $gold-400; }
}
@@ -0,0 +1,95 @@
import * as React from 'react';
import styles from './TopNav.module.scss';
import { ScreenKey } from './types';
import { texts } from '../../texts';
interface ITab {
key: ScreenKey;
label: string;
}
const TABS: ITab[] = [
{ key: 'dashboard', label: texts.nav.dashboard },
{ key: 'requestTypes', label: texts.nav.requestTypes },
{ key: 'routes', label: texts.nav.routes },
{ key: 'resolve', label: texts.nav.resolve }
];
export interface ITopNavProps {
active: ScreenKey;
onSelect: (key: ScreenKey) => void;
}
function isTabActive(active: ScreenKey, tabKey: ScreenKey): boolean {
// Экран шагов маршрута — вложенный, подсвечиваем вкладку «Маршруты».
return active === tabKey || (active === 'routeSteps' && tabKey === 'routes');
}
export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
const tabsRef = React.useRef<HTMLDivElement>(null);
const activeTabRef = React.useRef<HTMLButtonElement>(null);
// Вкладки скроллятся по горизонтали, поэтому активная может оказаться за
// краем. Подкручиваем контейнер вручную (не scrollIntoView — тот способен
// утащить за собой прокрутку всей страницы SharePoint).
React.useEffect(() => {
const reveal = (): void => {
const container = tabsRef.current;
const tab = activeTabRef.current;
if (!container || !tab) return;
const left = tab.offsetLeft;
const width = tab.offsetWidth;
if (width > container.clientWidth) {
// Вкладка шире видимой полосы — показываем её начало, чтобы подпись
// читалась с первого слова.
container.scrollLeft = left;
} else if (left < container.scrollLeft) {
container.scrollLeft = left;
} else if (left + width > container.scrollLeft + container.clientWidth) {
container.scrollLeft = left + width - container.clientWidth;
}
};
reveal();
// Шрифты (Lora/Inter) подключаются рантаймом: пока они не применились,
// ширины вкладок другие и позиция прокрутки получается устаревшей.
const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
let cancelled = false;
if (fonts?.ready) {
fonts.ready.then(() => { if (!cancelled) reveal(); }).catch(() => { /* не критично */ });
}
window.addEventListener('resize', reveal);
return () => {
cancelled = true;
window.removeEventListener('resize', reveal);
};
}, [active]);
return (
<nav className={styles.topnav}>
<div className={styles.brand}>
<div className={styles.mark}>{texts.nav.markLetter}</div>
<div className={styles.brandText}>{texts.nav.title}</div>
</div>
<div className={styles.tabs} ref={tabsRef}>
{TABS.map(tab => {
const on = isTabActive(active, tab.key);
return (
<button
key={tab.key}
ref={on ? activeTabRef : undefined}
className={`${styles.tab} ${on ? styles.on : ''}`}
onClick={() => onSelect(tab.key)}
>
{tab.label}
</button>
);
})}
</div>
</nav>
);
};
@@ -0,0 +1 @@
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve';
@@ -0,0 +1,17 @@
@import '../../../../styles/design-tokens';
.sectionsLabel {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.6px;
color: $gray-400;
font-weight: 600;
margin-bottom: 12px;
}
.sectionsDivider {
height: 1px;
background: linear-gradient(90deg, $gold-400, transparent 70%);
border: 0;
margin: 6px 0 22px;
}
@@ -0,0 +1,58 @@
import * as React from 'react';
import styles from './DashboardScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api';
import { ScreenKey } from '../app/types';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
import { AsyncList } from '../../hooks/useAsyncList';
import { CardGrid, NavCard, StatCard, StatRow } from '../ui';
export interface IDashboardScreenProps {
api: ApprovalRouteApi;
onNavigate: (key: ScreenKey) => void;
}
// Пока список грузится или запрос упал — показываем прочерк, а не «0»,
// чтобы не соврать пользователю про реальное количество записей.
function statValue(list: AsyncList<unknown>): React.ReactNode {
if (list.isLoading || list.error) return texts.common.empty;
return list.items.length;
}
export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNavigate }) => {
const requestTypes = useRequestTypes(api);
const routes = useTemplateRoutes(api);
return (
<div>
<StatRow>
<StatCard label={texts.dashboard.requestTypesStat} value={statValue(requestTypes)} />
<StatCard label={texts.dashboard.routesStat} value={statValue(routes)} />
</StatRow>
<div className={styles.sectionsLabel}>{texts.dashboard.sections}</div>
<hr className={styles.sectionsDivider} />
<CardGrid>
<NavCard
icon="clipboard"
title={texts.dashboard.requestTypesCard.title}
description={texts.dashboard.requestTypesCard.description}
onClick={() => onNavigate('requestTypes')}
/>
<NavCard
icon="route"
title={texts.dashboard.routesCard.title}
description={texts.dashboard.routesCard.description}
onClick={() => onNavigate('routes')}
/>
<NavCard
icon="search"
title={texts.dashboard.resolveCard.title}
description={texts.dashboard.resolveCard.description}
onClick={() => onNavigate('resolve')}
/>
</CardGrid>
</div>
);
};
@@ -0,0 +1,148 @@
import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api';
import { RequestType } from '../../models';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import {
ActionsCell,
Alert,
Breadcrumbs,
Button,
ConfirmDialog,
DataTable,
Field,
FormGrid,
FormModal,
IconButton,
PageHeader,
PrimaryCell,
TextInput
} from '../ui';
export interface IRequestTypesScreenProps {
api: ApprovalRouteApi;
}
interface IFormState {
name: string;
pageUrl: string;
}
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' };
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => {
const list = useRequestTypes(api);
const [editing, setEditing] = React.useState<RequestType | undefined>();
const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const submit = useAsyncSubmit();
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
const openCreate = (): void => {
setEditing(undefined);
setForm(EMPTY_FORM);
setShowForm(true);
};
const openEdit = (item: RequestType): void => {
setEditing(item);
setForm({ name: item.name, pageUrl: item.pageUrl });
setShowForm(true);
};
const submitForm = (): void => {
if (!form.name.trim() || !form.pageUrl.trim()) {
submit.fail(texts.requestTypes.validationRequired);
return;
}
const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() };
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
submit.run(() =>
request.then(() => {
setShowForm(false);
list.reload();
})
);
};
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
<PageHeader
icon="clipboard"
subtitle={texts.requestTypes.subtitle}
title={texts.requestTypes.title}
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypes.add}</Button>}
/>
{list.error && <Alert kind="err">{list.error}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{
key: 'actions',
header: '',
width: '100px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={list.items}
keyOf={r => r.id}
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
/>
{showForm && (
<FormModal
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={() => setShowForm(false)}
>
<FormGrid>
<Field label={texts.requestTypes.nameLabel} required full>
<TextInput
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
placeholder={texts.requestTypes.namePlaceholder}
/>
</Field>
<Field label={texts.requestTypes.pageUrlLabel} required full hint={texts.requestTypes.pageUrlHint}>
<TextInput
value={form.pageUrl}
onChange={e => setForm({ ...form, pageUrl: e.target.value })}
placeholder={texts.requestTypes.pageUrlPlaceholder}
/>
</Field>
</FormGrid>
</FormModal>
)}
{deleteTarget.target && (
<ConfirmDialog
title={texts.requestTypes.deleteTitle}
text={texts.requestTypes.deleteText(deleteTarget.target.name)}
confirmLabel={texts.common.remove}
busy={deleteTarget.isBusy}
onCancel={deleteTarget.cancel}
onConfirm={deleteTarget.confirm}
/>
)}
</div>
);
};
@@ -0,0 +1,3 @@
.runButtonRow {
margin-top: 18px;
}
@@ -0,0 +1,185 @@
import * as React from 'react';
import styles from './ResolveScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { Project, RouteStepStatus } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useResolveRoute } from '../../hooks/useResolveRoute';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
import {
Alert,
AsyncSearchableSelect,
Breadcrumbs,
Button,
CellNote,
DataTable,
Field,
FormGrid,
PageHeader,
Panel,
PrimaryCell,
SearchableSelect,
Select
} from '../ui';
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<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
const requestTypes = useRequestTypes(api);
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>(RouteStepStatus.Approval);
const resolved = useResolveRoute(api);
const employees = usePortalEmployees(dictionary);
const searchProjects = React.useCallback(
(query: string, page: number) => {
if (!dictionary) {
return Promise.reject(new Error(texts.resolve.projectDictionaryLoading));
}
return dictionary.searchProjects(query, page);
},
[dictionary]
);
const run = (): void => {
if (!requestTypeId) {
resolved.fail(texts.resolve.validationRequestType);
return;
}
resolved.resolve({
requestTypeId: Number(requestTypeId),
userEmail: userEmail.trim() || undefined,
projectId: projectId.trim() || undefined,
status
});
};
const result = resolved.result;
const unresolvedCount = result ? result.steps.filter(s => !s.executorEmail).length : 0;
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.resolve.breadcrumb }]} />
<PageHeader icon="search" subtitle={texts.resolve.subtitle} title={texts.resolve.title} />
<Panel>
<FormGrid>
<Field label={texts.resolve.requestTypeLabel} required full>
<Select
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.resolve.requestTypePlaceholder}
value={requestTypeId}
onChange={e => setRequestTypeId(e.target.value)}
/>
</Field>
<Field
label={texts.resolve.userEmailLabel}
hint={currentUserEmail ? texts.resolve.userEmailHint(currentUserEmail) : texts.resolve.userEmailHintUnknown}
>
<SearchableSelect
options={employees.items.map(toEmployeeOption)}
value={userEmail}
loading={employees.isLoading}
allowClear
clearLabel={texts.resolve.userEmailClear}
placeholder={texts.resolve.userEmailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={setUserEmail}
/>
</Field>
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
<AsyncSearchableSelect<Project>
value={projectId}
selectedLabel={projectLabel}
disabled={!dictionary}
allowClear
clearLabel={texts.resolve.projectClear}
placeholder={dictionary ? texts.resolve.projectPlaceholder : texts.resolve.projectLoadingPlaceholder}
searchPlaceholder={texts.resolve.projectSearchPlaceholder}
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 label={texts.resolve.statusLabel} required>
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
</Field>
</FormGrid>
<div className={styles.runButtonRow}>
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
</Button>
</div>
</Panel>
{resolved.error && <Alert kind="err">{resolved.error}</Alert>}
{result && (
<>
<Alert kind="ok">
{texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)}
</Alert>
{unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>}
<DataTable
columns={[
{ key: 'order', header: texts.resolve.columnOrderNo, width: '50px', render: r => r.orderNo },
{
key: 'executor',
header: texts.resolve.columnExecutor,
render: r => {
const employee = findEmployeeByEmail(employees.items, r.executorEmail);
return (
<div>
<PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell>
{employee && <CellNote>{employee.email}</CellNote>}
{r.warning && <CellNote kind="warn">{r.warning}</CellNote>}
</div>
);
}
},
{
key: 'position',
header: texts.resolve.columnPosition,
render: r => (
<div>
{r.position ?? texts.common.empty}
{!r.position && !r.warning && r.executorEmail && <CellNote>{texts.resolve.positionMissing}</CellNote>}
</div>
)
},
{
key: 'reassign',
header: texts.resolve.columnReassign,
align: 'center',
render: r => (r.allowReassignToOtherApprovers ? texts.resolve.reassignAllowed : texts.common.no)
}
]}
rows={result.steps}
keyOf={r => r.id}
emptyMessage={texts.resolve.emptySteps}
/>
</>
)}
</div>
);
};
@@ -0,0 +1,188 @@
import * as React from 'react';
import { Company, Department, RequestType, RouteType, TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
export interface IRouteFormModalProps {
api: ApprovalRouteApi;
/** Undefined — создание нового маршрута, иначе редактирование существующего. */
editing?: TemplateRoute;
/** Тип заявки, под которым открыли форму создания (например, из активного фильтра списка). */
initialRequestTypeId: string;
requestTypes: RequestType[];
companies: Company[];
departments: Department[];
dictionaryLoading: boolean;
onSaved: () => void;
onClose: () => void;
}
interface IFormState {
requestTypeId: string;
routeType: RouteType;
companyId: string;
departmentId: string;
byProject: boolean;
requiresControl: boolean;
}
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
.filter(v => v !== RouteType.None)
.map(v => ({ value: v, label: routeTypeLabels[v] }));
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
if (!editing) {
return {
requestTypeId: initialRequestTypeId,
routeType: RouteType.Sequential,
companyId: '',
departmentId: '',
byProject: false,
requiresControl: false
};
}
return {
requestTypeId: String(editing.requestTypeId),
routeType: editing.routeType,
companyId: editing.companyId,
departmentId: editing.departmentId ?? '',
byProject: editing.byProject,
requiresControl: editing.requiresControl
};
}
/** Форма создания/редактирования маршрута — вынесена из RoutesScreen, чтобы тот не разрастался. */
export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
api,
editing,
initialRequestTypeId,
requestTypes,
companies,
departments,
dictionaryLoading,
onSaved,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId));
const submit = useAsyncSubmit();
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
// иначе пользователь листает ~700 позиций из всех юрлиц сразу.
const departmentsForSelectedCompany = React.useMemo(
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
[departments, form.companyId]
);
const submitForm = (): void => {
if (!editing && !form.requestTypeId) {
submit.fail(texts.routes.validationRequestType);
return;
}
if (!form.companyId.trim()) {
submit.fail(texts.routes.validationCompany);
return;
}
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
const request = editing
? api.templateRoutes.update(editing.id, {
routeType: form.routeType,
companyId: form.companyId.trim(),
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
})
: api.templateRoutes.create({
routeType: form.routeType,
requestTypeId: Number(form.requestTypeId),
companyId: form.companyId.trim(),
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
});
submit.run(() =>
request.then(() => {
onSaved();
onClose();
})
);
};
return (
<FormModal
title={editing ? texts.routes.editTitle(editing.id) : texts.routes.createTitle}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={onClose}
>
<FormGrid>
<Field label={texts.routes.requestTypeLabel} required full>
<Select
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.routes.requestTypePlaceholder}
value={form.requestTypeId}
disabled={!!editing}
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
/>
</Field>
<Field label={texts.routes.routeTypeLabel} required full>
<Select
options={ROUTE_TYPE_OPTIONS}
value={form.routeType}
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
/>
</Field>
<Field label={texts.routes.companyLabel} required full hint={texts.routes.companyHint}>
<SearchableSelect
options={companies.map(c => ({ value: c.id, label: c.name }))}
value={form.companyId}
loading={dictionaryLoading}
placeholder={texts.routes.companyPlaceholder}
searchPlaceholder={texts.routes.companySearchPlaceholder}
onChange={companyId =>
// Смена компании обнуляет отдел: прежний принадлежал другой
// компании, и бэкенд не нашёл бы такой маршрут при resolve.
setForm({ ...form, companyId, departmentId: '' })
}
/>
</Field>
<Field
label={texts.routes.departmentLabel}
full
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
>
<SearchableSelect
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
value={form.departmentId}
loading={dictionaryLoading}
disabled={!form.companyId}
allowClear
clearLabel={texts.routes.departmentPlaceholder}
placeholder={texts.routes.departmentPlaceholder}
searchPlaceholder={texts.routes.departmentSearchPlaceholder}
emptyMessage={texts.routes.departmentEmpty}
onChange={departmentId => setForm({ ...form, departmentId })}
/>
</Field>
<Field label={texts.routes.byProjectLabel}>
<Checkbox
label={texts.routes.byProjectCheckbox}
checked={form.byProject}
onChange={v => setForm({ ...form, byProject: v })}
/>
</Field>
<Field label={texts.routes.controlLabel}>
<Checkbox
label={texts.routes.controlCheckbox}
checked={form.requiresControl}
onChange={v => setForm({ ...form, requiresControl: v })}
/>
</Field>
</FormGrid>
</FormModal>
);
};
@@ -0,0 +1,223 @@
import * as React from 'react';
import {
Employee,
HeadType,
ProjectRole,
RouteStepStatus,
StepExecutorKind,
TemplateRoute,
TemplateRouteStep
} from '../../models';
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor';
import { toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
export interface IRouteStepFormModalProps {
api: ApprovalRouteApi;
route: TemplateRoute;
/** Undefined — создание нового шага, иначе редактирование существующего. */
editing?: TemplateRouteStep;
nextOrderNo: number;
employees: Employee[];
employeesLoading: boolean;
onSaved: (savedStep: TemplateRouteStep) => void;
onClose: () => void;
}
interface IFormState {
orderNo: string;
status: RouteStepStatus;
kind: StepExecutorKind;
executorEmail: string;
position: string;
projectRole: ProjectRole;
headType: HeadType;
allowReassignToOtherApprovers: boolean;
}
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
const PROJECT_ROLE_OPTIONS = Object.values(ProjectRole)
.filter(v => v !== ProjectRole.None)
.map(v => ({ value: v, label: projectRoleLabels[v] }));
const HEAD_TYPE_OPTIONS = Object.values(HeadType)
.filter(v => v !== HeadType.None)
.map(v => ({ value: v, label: headTypeLabels[v] }));
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) {
return {
orderNo: String(nextOrderNo),
status: RouteStepStatus.Approval,
kind: 'email',
executorEmail: '',
position: '',
projectRole: ProjectRole.ProjectManager,
headType: HeadType.Chief,
allowReassignToOtherApprovers: false
};
}
return {
orderNo: String(editing.orderNo),
status: editing.status,
kind: detectExecutorKind(editing),
executorEmail: editing.executorEmail ?? '',
position: editing.position ?? '',
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers
};
}
/** Форма создания/редактирования шага маршрута — вынесена из RouteStepsScreen. */
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
api,
route,
editing,
nextOrderNo,
employees,
employeesLoading,
onSaved,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
const submit = useAsyncSubmit();
const submitForm = (): void => {
const orderNo = Number(form.orderNo);
if (!orderNo || orderNo < 1) {
submit.fail(texts.routeSteps.validationOrderNo);
return;
}
if (form.kind === 'email' && !form.executorEmail.trim()) {
submit.fail(texts.routeSteps.validationExecutor);
return;
}
if (form.kind === 'position' && !form.position.trim()) {
submit.fail(texts.routeSteps.validationPosition);
return;
}
const payload = {
orderNo,
status: form.status,
allowReassignToOtherApprovers: form.allowReassignToOtherApprovers,
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
position: form.kind === 'position' ? form.position.trim() : null,
projectRole: form.kind === 'projectRole' ? form.projectRole : null,
headType: form.kind === 'headType' ? form.headType : null
};
const request = editing
? api.templateRouteSteps.update(route.id, editing.id, payload)
: api.templateRouteSteps.create(route.id, payload);
submit.run(() =>
request.then(step => {
onSaved(step);
onClose();
})
);
};
return (
<FormModal
title={editing ? texts.routeSteps.editTitle(editing.orderNo) : texts.routeSteps.createTitle}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={onClose}
>
<FormGrid>
<Field label={texts.routeSteps.orderNoLabel} required>
<TextInput
type="number"
min={1}
value={form.orderNo}
onChange={e => setForm({ ...form, orderNo: e.target.value })}
/>
</Field>
<Field label={texts.routeSteps.statusLabel} required>
<Select
options={STATUS_OPTIONS}
value={form.status}
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
/>
</Field>
<Field label={texts.routeSteps.kindLabel} required full hint={texts.routeSteps.kindHint}>
<RadioGroup
name="executorKind"
value={form.kind}
onChange={kind => setForm({ ...form, kind })}
options={[
{ value: 'email', label: texts.routeSteps.kindEmail },
{ value: 'position', label: texts.routeSteps.kindPosition },
{
value: 'projectRole',
label: texts.routeSteps.kindProjectRole,
disabled: !route.byProject,
disabledHint: texts.routeSteps.kindProjectRoleDisabled
},
{ value: 'headType', label: texts.routeSteps.kindHeadType }
]}
/>
</Field>
{form.kind === 'email' && (
<Field label={texts.routeSteps.executorLabel} required full>
<SearchableSelect
options={employees.map(toEmployeeOption)}
value={form.executorEmail}
loading={employeesLoading}
placeholder={texts.routeSteps.executorPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={executorEmail => setForm({ ...form, executorEmail })}
/>
</Field>
)}
{form.kind === 'position' && (
<Field label={texts.routeSteps.positionLabel} required full hint={texts.routeSteps.positionHint}>
<TextInput
value={form.position}
onChange={e => setForm({ ...form, position: e.target.value })}
placeholder={texts.routeSteps.positionPlaceholder}
/>
</Field>
)}
{form.kind === 'projectRole' && (
<Field label={texts.routeSteps.projectRoleLabel} required full>
<Select
options={PROJECT_ROLE_OPTIONS}
value={form.projectRole}
onChange={e => setForm({ ...form, projectRole: e.target.value as ProjectRole })}
/>
</Field>
)}
{form.kind === 'headType' && (
<Field label={texts.routeSteps.headTypeLabel} required full>
<Select
options={HEAD_TYPE_OPTIONS}
value={form.headType}
onChange={e => setForm({ ...form, headType: e.target.value as HeadType })}
/>
</Field>
)}
<Field label={texts.routeSteps.reassignLabel} full>
<Checkbox
label={texts.routeSteps.reassignCheckbox}
checked={form.allowReassignToOtherApprovers}
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
/>
</Field>
</FormGrid>
</FormModal>
);
};
@@ -0,0 +1,164 @@
import * as React from 'react';
import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import {
ActionsCell,
Alert,
Badge,
Breadcrumbs,
Button,
CellNote,
ConfirmDialog,
DataTable,
IconButton,
PageHeader,
PrimaryCell
} from '../ui';
export interface IRouteStepsScreenProps {
api: ApprovalRouteApi;
route: TemplateRoute;
requestTypeName: string;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
onBack: () => void;
}
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => {
const steps = useRouteSteps(api, route.id);
const employees = usePortalEmployees(dictionary);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
const [showForm, setShowForm] = React.useState(false);
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
item => api.templateRouteSteps.remove(route.id, item.id),
steps.reload
);
const openCreate = (): void => {
setEditing(undefined);
setShowForm(true);
};
const openEdit = (item: TemplateRouteStep): void => {
setEditing(item);
setShowForm(true);
};
const handleSaved = (savedStep: TemplateRouteStep): void => {
setSavedWarning(savedStep.warning ?? undefined);
steps.reload();
};
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
return (
<div>
<Breadcrumbs
items={[
{ label: texts.common.home },
{ label: texts.routes.breadcrumb, onClick: onBack },
{ label: texts.routeSteps.breadcrumb(route.id) }
]}
/>
<PageHeader
icon="flag-check"
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])}
title={texts.routeSteps.title(route.id)}
actions={
<>
<Button variant="ghost" icon="arrow-left" onClick={onBack}>{texts.routeSteps.back}</Button>
<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routeSteps.add}</Button>
</>
}
/>
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
{
key: 'executor',
header: texts.routeSteps.columnExecutor,
render: r => {
const executor = describeStepExecutor(r, employees.items);
return (
<div>
<PrimaryCell>{executor.primary}</PrimaryCell>
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
</div>
);
}
},
{
key: 'status',
header: texts.routeSteps.columnStatus,
render: r => (
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
)
},
{
key: 'reassign',
header: texts.routeSteps.columnReassign,
align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'actions',
header: '',
width: '100px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={steps.items}
keyOf={r => r.id}
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
/>
{showForm && (
<RouteStepFormModal
api={api}
route={route}
editing={editing}
nextOrderNo={nextOrderNo}
employees={employees.items}
employeesLoading={employees.isLoading}
onSaved={handleSaved}
onClose={() => setShowForm(false)}
/>
)}
{deleteTarget.target && (
<ConfirmDialog
title={texts.routeSteps.deleteTitle}
text={texts.routeSteps.deleteText(deleteTarget.target.orderNo)}
confirmLabel={texts.common.remove}
busy={deleteTarget.isBusy}
onCancel={deleteTarget.cancel}
onConfirm={deleteTarget.confirm}
/>
)}
</div>
);
};
@@ -0,0 +1,4 @@
.filter {
margin-bottom: 18px;
max-width: 320px;
}
@@ -0,0 +1,160 @@
import * as React from 'react';
import styles from './RoutesScreen.module.scss';
import { RouteFormModal } from './RouteFormModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteType, TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import {
ActionsCell,
Alert,
Badge,
Breadcrumbs,
Button,
ConfirmDialog,
DataTable,
Field,
IconButton,
PageHeader,
PrimaryCell,
Select
} from '../ui';
export interface IRoutesScreenProps {
api: ApprovalRouteApi;
dictionary?: PortalDictionaryClient;
onOpenSteps: (route: TemplateRoute) => void;
}
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
const requestTypes = useRequestTypes(api);
const portalDictionaries = usePortalDictionaries(dictionary);
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState('');
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
const [showForm, setShowForm] = React.useState(false);
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
// показываем усечённый Guid — чтобы строка таблицы не выглядела пустой.
const companyName = (id: string): string => portalDictionaries.companies.find(c => c.id === id)?.name ?? shortGuid(id);
const departmentName = (id: string): string => portalDictionaries.departments.find(d => d.id === id)?.name ?? shortGuid(id);
const openCreate = (): void => {
setEditing(undefined);
setShowForm(true);
};
const openEdit = (item: TemplateRoute): void => {
setEditing(item);
setShowForm(true);
};
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routes.breadcrumb }]} />
<PageHeader
icon="route"
subtitle={texts.routes.subtitle}
title={texts.routes.title}
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routes.add}</Button>}
/>
<div className={styles.filter}>
<Field label={texts.routes.filterLabel}>
<Select
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.routes.filterAll}
value={filterRequestTypeId}
onChange={e => setFilterRequestTypeId(e.target.value)}
/>
</Field>
</div>
{portalDictionaries.error && <Alert kind="warn">{texts.routes.dictionaryFailed(portalDictionaries.error)}</Alert>}
{routes.error && <Alert kind="err">{routes.error}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{
key: 'requestType',
header: texts.routes.columnRequestType,
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
},
{
key: 'routeType',
header: texts.routes.columnRouteType,
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
},
{ key: 'company', header: texts.routes.columnCompany, render: r => companyName(r.companyId) },
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
{
key: 'byProject',
header: texts.routes.columnByProject,
align: 'center',
render: r => (r.byProject ? <Badge variant="ok">{texts.common.yes}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'control',
header: texts.routes.columnControl,
align: 'center',
render: r => (r.requiresControl ? <Badge variant="gold">{texts.routes.controlRequired}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'actions',
header: '',
width: '160px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={routes.items}
keyOf={r => r.id}
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
/>
{showForm && (
<RouteFormModal
api={api}
editing={editing}
initialRequestTypeId={filterRequestTypeId}
requestTypes={requestTypes.items}
companies={portalDictionaries.companies}
departments={portalDictionaries.departments}
dictionaryLoading={portalDictionaries.isLoading}
onSaved={routes.reload}
onClose={() => setShowForm(false)}
/>
)}
{deleteTarget.target && (
<ConfirmDialog
title={texts.routes.deleteTitle}
text={texts.routes.deleteText(deleteTarget.target.id)}
confirmLabel={texts.common.remove}
busy={deleteTarget.isBusy}
onCancel={deleteTarget.cancel}
onConfirm={deleteTarget.confirm}
/>
)}
</div>
);
};
@@ -0,0 +1,7 @@
import * as React from 'react';
import styles from './DataTable.module.scss';
/** Ряд кнопок действий в последней колонке таблицы. */
export const ActionsCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span className={styles.actionsCell}>{children}</span>
);
@@ -0,0 +1,17 @@
@import '../../../../styles/design-tokens';
.alert {
padding: 12px 16px;
border-radius: $r-md;
font-size: 12.5px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
border: 1px solid;
line-height: 1.5;
}
.warn { background: $gold-50; color: $amber-700; border-color: $gold-300; }
.ok { background: $green-50; color: $green-600; border-color: #BBF7D0; }
.err { background: $red-50; color: $red-500; border-color: #FECACA; }
@@ -0,0 +1,19 @@
import * as React from 'react';
import styles from './Alert.module.scss';
import { Icon } from './Icon';
export type AlertKind = 'warn' | 'ok' | 'err';
export interface IAlertProps {
kind: AlertKind;
children: React.ReactNode;
}
const ICONS = { warn: 'warning', ok: 'check', err: 'close' } as const;
export const Alert: React.FC<IAlertProps> = ({ kind, children }) => (
<div className={`${styles.alert} ${styles[kind]}`}>
<Icon name={ICONS[kind]} size="sm" />
<span>{children}</span>
</div>
);
@@ -0,0 +1,217 @@
import * as React from 'react';
import styles from './SearchableSelect.module.scss';
import { Icon } from './Icon';
import { texts } from '../../texts';
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 = texts.select.placeholder,
searchPlaceholder = texts.select.searchPlaceholder,
disabled,
allowClear,
clearLabel = texts.select.notSelected,
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 listRef = React.useRef<HTMLDivElement>(null);
const sentinelRef = React.useRef<HTMLDivElement>(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]);
// Бесконечная прокрутка: как только сентинел внизу списка попадает в
// видимую область скролл-контейнера — подгружаем следующую страницу.
// root указываем явно (сам .list, а не viewport) — иначе браузер считает
// видимость относительно всей страницы, а не прокручиваемого дропдауна.
React.useEffect(() => {
if (!open || !hasMore || loading || loadingMore) return;
const rootEl = listRef.current;
const sentinelEl = sentinelRef.current;
if (!rootEl || !sentinelEl) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0]?.isIntersecting) runSearch(query, page + 1, true);
},
{ root: rootEl, rootMargin: '64px' }
);
observer.observe(sentinelEl);
return () => observer.disconnect();
}, [open, hasMore, loading, loadingMore, page, query, runSearch]);
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} ref={listRef}>
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
{!loading && error && <div className={styles.errorText}>{error}</div>}
{!loading && !error && items.length === 0 && (
<div className={styles.empty}>{texts.select.nothingFound}</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 ref={sentinelRef} className={styles.sentinel}>
{loadingMore && 'Загрузка...'}
</div>
)}
</div>
{allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
{clearLabel}
</button>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
@import '../../../../styles/design-tokens';
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 11px;
border-radius: $r-full;
font-size: 11px;
font-weight: 600;
letter-spacing: .2px;
white-space: nowrap;
&::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
}
.ok { background: $green-50; color: $green-600; }
.gold { background: $gold-100; color: $amber-700; }
.neutral { background: $gray-100; color: $gray-500; }
.blue { background: $blue-50; color: $blue-600; }
.red { background: $red-50; color: $red-500; }
.count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
border-radius: 11px;
background: $navy-900;
color: $gold-300;
font-size: 11px;
font-weight: 600;
padding: 0 6px;
}
@@ -0,0 +1,17 @@
import * as React from 'react';
import styles from './Badge.module.scss';
export type BadgeVariant = 'ok' | 'gold' | 'neutral' | 'blue' | 'red';
export interface IBadgeProps {
variant?: BadgeVariant;
children: React.ReactNode;
}
export const Badge: React.FC<IBadgeProps> = ({ variant = 'neutral', children }) => (
<span className={`${styles.badge} ${styles[variant]}`}>{children}</span>
);
export const CountBadge: React.FC<{ count: number }> = ({ count }) => (
<span className={styles.count}>{count}</span>
);
@@ -0,0 +1,27 @@
import * as React from 'react';
import styles from './PageChrome.module.scss';
export interface IBreadcrumbItem {
label: string;
/** Без onClick пункт неинтерактивен — это текущая страница. */
onClick?: () => void;
}
export interface IBreadcrumbsProps {
items: IBreadcrumbItem[];
}
export const Breadcrumbs: React.FC<IBreadcrumbsProps> = ({ items }) => (
<div className={styles.bread}>
{items.map((item, index) => (
<React.Fragment key={item.label}>
{index > 0 && <span className={styles.sep}>&#9670;</span>}
{item.onClick ? (
<button className={styles.breadLink} onClick={item.onClick}>{item.label}</button>
) : (
<span>{item.label}</span>
)}
</React.Fragment>
))}
</div>
);
@@ -0,0 +1,81 @@
@import '../../../../styles/design-tokens';
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 20px;
border-radius: $r-sm;
font-size: 13px;
font-weight: 500;
font-family: $font-sans;
cursor: pointer;
border: 1px solid transparent;
transition: .18s;
white-space: nowrap;
letter-spacing: .15px;
text-decoration: none;
&:disabled {
opacity: .5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
}
.primary {
background: $navy-800;
color: #fff;
border-color: $navy-800;
&:hover:not(:disabled) { background: $navy-700; box-shadow: $sh-2; }
}
.accent {
@include btn-accent;
}
.secondary {
background: #fff;
color: $navy-800;
border-color: $gray-300;
&:hover:not(:disabled) { border-color: $gold-500; color: $navy-900; }
}
.ghost {
background: transparent;
color: $navy-700;
border-color: transparent;
&:hover:not(:disabled) { background: $navy-100; }
}
.danger {
background: #fff;
color: $red-500;
border-color: $gray-300;
&:hover:not(:disabled) { border-color: $red-500; background: $red-50; }
}
.sm {
padding: 7px 14px;
font-size: 12.5px;
}
.iconButton {
width: 33px;
height: 33px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: $r-sm;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
color: $gray-400;
transition: .18s;
&:hover:not(:disabled) { color: $navy-800; background: $navy-50; border-color: $gray-200; }
&.danger:hover:not(:disabled) { color: $red-500; background: $red-50; }
&:disabled { opacity: .4; cursor: not-allowed; }
}
@@ -0,0 +1,31 @@
import * as React from 'react';
import styles from './Button.module.scss';
import { Icon, IconName } from './Icon';
export type ButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger';
export interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: 'md' | 'sm';
icon?: IconName;
}
export const Button: React.FC<IButtonProps> = ({
variant = 'secondary',
size = 'md',
icon,
className,
children,
...rest
}) => {
const classes = [styles.btn, styles[variant], size === 'sm' ? styles.sm : '', className]
.filter(Boolean)
.join(' ');
return (
<button type="button" className={classes} {...rest}>
{icon && <Icon name={icon} size="sm" />}
{children}
</button>
);
};
@@ -0,0 +1,94 @@
@import '../../../../styles/design-tokens';
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.navCard {
background: #fff;
border-radius: $r-md;
padding: 22px 24px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: $sh-1;
cursor: pointer;
@include card-hover-gold;
border: 1px solid $gray-200;
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: $grad-gold-v;
opacity: 0;
transition: .22s;
}
&:hover::before { opacity: 1; }
}
.navIcon {
width: 44px;
height: 44px;
border-radius: $r-md;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: $navy-50;
color: $navy-700;
border: 1px solid $navy-100;
transition: .22s;
.navCard:hover & { background: $gold-50; color: $gold-600; border-color: $gold-300; }
}
.navTitle { font-size: 14.5px; font-weight: 600; color: $navy-900; letter-spacing: .1px; }
.navDesc { font-size: 12px; color: $gray-400; margin-top: 2px; }
.navArrow {
color: $gray-300;
margin-left: auto;
display: flex;
transition: .22s;
.navCard:hover & { color: $gold-500; transform: translateX(3px); }
}
.statRow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.statCard {
background: #fff;
border-radius: $r-md;
padding: 18px 20px;
box-shadow: $sh-1;
border: 1px solid $gray-200;
}
.statLabel {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: $gray-400;
font-weight: 600;
margin-bottom: 6px;
}
.statValue {
font-family: $font-serif;
font-size: 26px;
font-weight: 600;
color: $navy-900;
}
@@ -0,0 +1,11 @@
import * as React from 'react';
import styles from './Card.module.scss';
export interface ICardGridProps {
children: React.ReactNode;
}
/** Адаптивная сетка карточек-разделов. */
export const CardGrid: React.FC<ICardGridProps> = ({ children }) => (
<div className={styles.grid}>{children}</div>
);
@@ -0,0 +1,17 @@
import * as React from 'react';
import styles from './DataTable.module.scss';
import { Icon } from './Icon';
export interface ICellNoteProps {
children: React.ReactNode;
/** 'warn' — амбер с иконкой (например, причина от бэкенда), 'muted' — нейтральная пометка. */
kind?: 'warn' | 'muted';
}
/** Пояснение под основным значением ячейки — например, причина, по которой оно пустое. */
export const CellNote: React.FC<ICellNoteProps> = ({ children, kind = 'muted' }) => (
<span className={`${styles.cellNote} ${kind === 'warn' ? styles.cellNoteWarn : ''}`}>
{kind === 'warn' && <Icon name="warning" size="sm" />}
<span>{children}</span>
</span>
);
@@ -0,0 +1,22 @@
import * as React from 'react';
import styles from './FormField.module.scss';
export interface ICheckboxProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export const Checkbox: React.FC<ICheckboxProps> = ({ label, checked, onChange, disabled }) => (
<label className={styles.checkboxRow}>
<input
type="checkbox"
className={styles.checkbox}
checked={checked}
disabled={disabled}
onChange={e => onChange(e.target.checked)}
/>
{label}
</label>
);
@@ -0,0 +1,33 @@
import * as React from 'react';
import styles from './Modal.module.scss';
import { Button } from './Button';
import { texts } from '../../texts';
export interface IConfirmDialogProps {
title: string;
text: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
busy?: boolean;
}
export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
title,
text,
confirmLabel,
onConfirm,
onCancel,
busy
}) => (
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
<div className={`${styles.box} ${styles.narrow}`}>
<div className={styles.title}>{title}</div>
<p className={styles.text}>{text}</p>
<div className={styles.narrowFooter}>
<Button variant="secondary" onClick={onCancel} disabled={busy}>{texts.common.cancel}</Button>
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
</div>
</div>
</div>
);
@@ -0,0 +1,100 @@
@import '../../../../styles/design-tokens';
.wrap {
background: #fff;
border-radius: $r-lg;
box-shadow: $sh-1;
overflow: hidden;
border: 1px solid $gray-200;
}
// Внутренний скролл-контейнер: если колонок больше, чем помещается в узкую
// колонку страницы (например, в редакторе SharePoint), таблица должна
// скроллиться по горизонтали, а не обрезаться по overflow:hidden у .wrap.
.scroll {
overflow-x: auto;
}
.table {
// min-width, не width: при большом числе колонок таблица должна иметь
// право стать шире контейнера — тогда её подхватит .scroll (overflow-x).
min-width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
.table thead th {
text-align: left;
padding: 13px 18px;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: $gray-400;
font-weight: 600;
background: $gray-50;
border-bottom: 1px solid $gray-200;
white-space: nowrap;
}
.table tbody td {
padding: 15px 18px;
border-bottom: 1px solid $gray-100;
color: $gray-700;
vertical-align: middle;
}
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr { transition: .12s; }
.table tbody tr:hover { background: $gold-50; }
.stickyCell {
position: sticky;
right: 0;
}
.table thead th.stickyCell { z-index: 2; }
.table tbody td.stickyCell {
background: #fff;
box-shadow: -6px 0 6px -6px rgba(15, 36, 64, .12);
z-index: 1;
}
.table tbody tr:hover td.stickyCell { background: $gold-50; }
.primaryCell {
font-weight: 500;
color: $navy-900;
display: flex;
align-items: center;
gap: 10px;
}
.cellNote {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 4px;
max-width: 420px;
font-size: 11.5px;
line-height: 1.4;
color: $gray-500;
svg { margin-top: 1px; }
}
.cellNoteWarn { color: $amber-700; }
.actionsCell {
display: flex;
gap: 4px;
justify-content: flex-end;
align-items: center;
}
.empty {
padding: 48px 20px;
text-align: center;
color: $gray-400;
font-size: 13px;
}
@@ -0,0 +1,60 @@
import * as React from 'react';
import styles from './DataTable.module.scss';
export interface IColumn<T> {
key: string;
header: string;
render: (row: T) => React.ReactNode;
align?: 'left' | 'right' | 'center';
width?: string;
// Держит колонку у правого края видимой области при горизонтальной
// прокрутке — чтобы кнопки действий не уезжали за край на узких экранах.
sticky?: boolean;
}
export interface IDataTableProps<T> {
columns: IColumn<T>[];
rows: T[];
keyOf: (row: T) => string | number;
emptyMessage: string;
}
export function DataTable<T>({ columns, rows, keyOf, emptyMessage }: IDataTableProps<T>): React.ReactElement {
return (
<div className={styles.wrap}>
<div className={styles.scroll}>
<table className={styles.table}>
<thead>
<tr>
{columns.map(col => (
<th
key={col.key}
className={col.sticky ? styles.stickyCell : undefined}
style={{ width: col.width, textAlign: col.align ?? 'left' }}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={keyOf(row)}>
{columns.map(col => (
<td
key={col.key}
className={col.sticky ? styles.stickyCell : undefined}
style={{ textAlign: col.align ?? 'left' }}
>
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rows.length === 0 && <div className={styles.empty}>{emptyMessage}</div>}
</div>
);
}
@@ -0,0 +1,24 @@
import * as React from 'react';
import styles from './FormField.module.scss';
export interface IFieldProps {
label: string;
required?: boolean;
/** Занять обе колонки FormGrid. */
full?: boolean;
error?: string;
hint?: string;
children: React.ReactNode;
}
/** Подпись + контрол + текст ошибки/подсказки под ним. */
export const Field: React.FC<IFieldProps> = ({ label, required, full, error, hint, children }) => (
<div className={`${styles.field} ${full ? styles.full : ''}`}>
<span className={styles.label}>
{label} {required && <span className={styles.req}>*</span>}
</span>
{children}
{error && <span className={styles.errorText}>{error}</span>}
{!error && hint && <span className={styles.hint}>{hint}</span>}
</div>
);
@@ -0,0 +1,113 @@
@import '../../../../styles/design-tokens';
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px 22px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
// Grid-элементы по умолчанию не сжимаются уже своего min-content
// (min-width: auto) — из-за этого длинный текст/плейсхолдер в инпуте
// мог распирать колонку и вызывать горизонтальный скролл модалки.
min-width: 0;
}
.full { grid-column: 1 / -1; }
.label {
font-size: 12px;
font-weight: 500;
color: $gray-700;
letter-spacing: .2px;
}
.req { color: $gold-600; }
.input,
.select,
.textarea {
// border-box обязателен: width:100% + padding/border в content-box
// (браузерный дефолт) делает элемент шире контейнера на padding+border
// и распирает модалку в горизонтальный скролл.
box-sizing: border-box;
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
font-size: 13.5px;
font-family: $font-sans;
color: $gray-900;
transition: .18s;
background: #fff;
width: 100%;
@include input-focus-gold;
&::placeholder { color: $gray-400; }
&:disabled { background: $gray-50; color: $gray-400; cursor: not-allowed; }
}
.textarea { resize: vertical; min-height: 80px; }
.select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.errorText {
font-size: 11.5px;
color: $red-500;
}
.hint {
font-size: 11.5px;
color: $gray-400;
}
.checkboxRow {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
color: $gray-700;
cursor: pointer;
user-select: none;
}
.checkbox {
width: 17px;
height: 17px;
accent-color: $gold-500;
cursor: pointer;
}
.radioGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.radioRow {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
color: $gray-700;
cursor: pointer;
user-select: none;
&.disabled { opacity: .45; cursor: not-allowed; }
}
.radio {
width: 16px;
height: 16px;
accent-color: $gold-500;
cursor: pointer;
&:disabled { cursor: not-allowed; }
}
@@ -0,0 +1,11 @@
import * as React from 'react';
import styles from './FormField.module.scss';
export interface IFormGridProps {
children: React.ReactNode;
}
/** Двухколоночная сетка полей формы; поле помечает себя `full`, чтобы занять обе колонки. */
export const FormGrid: React.FC<IFormGridProps> = ({ children }) => (
<div className={styles.grid}>{children}</div>
);
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Alert } from './Alert';
import { Button } from './Button';
import { Modal } from './Modal';
import { texts } from '../../texts';
export interface IFormModalProps {
title: string;
/** Текст ошибки: и валидации формы, и ответа бэкенда. */
error?: string;
isSaving: boolean;
onSubmit: () => void;
onClose: () => void;
children: React.ReactNode;
}
/**
* Модальное окно формы: одинаковая обвязка «ошибка сверху, Отмена/Сохранить снизу»
* для всех справочников, чтобы экраны описывали только поля.
*/
export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, onSubmit, onClose, children }) => (
<Modal
title={title}
onClose={onClose}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
{isSaving ? texts.common.saving : texts.common.save}
</Button>
</>
}
>
{error && <Alert kind="err">{error}</Alert>}
{children}
</Modal>
);
@@ -0,0 +1,9 @@
.icon {
// Иконка не должна сжиматься внутри flex-строки (кнопки, алерты, ячейки).
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
@@ -0,0 +1,86 @@
import * as React from 'react';
import styles from './Icon.module.scss';
// Единый набор иконок в стиле референса (stroke, round caps, viewBox 24x24).
export type IconName =
| 'route'
| 'clipboard'
| 'flag-check'
| 'people'
| 'warning'
| 'check'
| 'close'
| 'plus'
| 'edit'
| 'trash'
| 'more'
| 'chevron-right'
| 'chevron-down'
| 'chevron-left'
| 'arrow-left'
| 'search'
| 'sort'
| 'mail'
| 'briefcase'
| 'flag'
| 'crown'
| 'refresh';
let spriteInjected = false;
const SPRITE_SVG = `
<symbol id="i-route" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M9 6h7a3 3 0 0 1 3 3v0a3 3 0 0 1-3 3H8a3 3 0 0 0-3 3v0a3 3 0 0 0 3 3h7"/></symbol>
<symbol id="i-clipboard" viewBox="0 0 24 24"><path d="M9 3h6v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z"/><rect x="5" y="5" width="14" height="16" rx="2"/><path d="M9 12h6M9 16h4"/></symbol>
<symbol id="i-flag-check" viewBox="0 0 24 24"><path d="M4 3v18"/><path d="M4 4h13l-2 4 2 4H4"/></symbol>
<symbol id="i-people" viewBox="0 0 24 24"><circle cx="9" cy="7" r="3"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><circle cx="17" cy="8" r="2.5"/><path d="M17 13.5a3.5 3.5 0 0 1 3.5 3.5v1"/></symbol>
<symbol id="i-warning" viewBox="0 0 24 24"><path d="M12 2 1 21h22z" stroke-linejoin="round"/><path d="M12 9v5M12 17h.01"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12"/><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24"><path d="m15 6-6 6 6 6"/></symbol>
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="M19 12H5m0 0 5-5m-5 5 5 5"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></symbol>
<symbol id="i-sort" viewBox="0 0 24 24"><path d="M7 3v18M7 3l-4 4m4-4 4 4M17 21V3m0 18-4-4m4 4 4-4"/></symbol>
<symbol id="i-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 6 8 7 8-7"/></symbol>
<symbol id="i-briefcase" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/></symbol>
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 3v18"/><path d="M5 4h13l-3 4 3 4H5"/></symbol>
<symbol id="i-crown" viewBox="0 0 24 24"><path d="m3 8 4 3 5-6 5 6 4-3-2 11H5z"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></symbol>
`;
function ensureSpriteInjected(): void {
if (spriteInjected || typeof document === 'undefined') return;
const holder = document.createElement('div');
holder.style.display = 'none';
holder.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${SPRITE_SVG}</svg>`;
document.body.appendChild(holder);
spriteInjected = true;
}
export type IconSize = 'sm' | 'md' | 'lg';
export interface IIconProps {
name: IconName;
size?: IconSize;
className?: string;
}
const SIZE_PX: Record<IconSize, number> = { sm: 16, md: 20, lg: 24 };
export const Icon: React.FC<IIconProps> = ({ name, size = 'md', className }) => {
React.useEffect(() => {
ensureSpriteInjected();
}, []);
const px = SIZE_PX[size];
return (
<svg width={px} height={px} className={[styles.icon, className].filter(Boolean).join(' ')}>
<use href={`#i-${name}`} />
</svg>
);
};
@@ -0,0 +1,19 @@
import * as React from 'react';
import styles from './Button.module.scss';
import { Icon, IconName } from './Icon';
export interface IIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: IconName;
danger?: boolean;
/** Подпись только для скринридера и подсказки — визуально видна лишь иконка. */
label: string;
}
export const IconButton: React.FC<IIconButtonProps> = ({ icon, danger, label, className, ...rest }) => {
const classes = [styles.iconButton, danger ? styles.danger : '', className].filter(Boolean).join(' ');
return (
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
<Icon name={icon} size="sm" />
</button>
);
};
@@ -0,0 +1,60 @@
@import '../../../../styles/design-tokens';
.overlay {
position: fixed;
inset: 0;
background: rgba(10,27,48,.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
backdrop-filter: blur(6px);
padding: 20px;
}
.box {
background: #fff;
border-radius: $r-lg;
box-shadow: $sh-modal;
width: 480px;
max-width: 100%;
max-height: calc(100vh - 40px);
overflow-y: auto;
border-top: 3px solid $gold-500;
}
.narrow { width: 420px; text-align: center; padding: 30px; }
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 26px 0;
}
.title {
font-family: $font-serif;
font-size: 19px;
font-weight: 600;
color: $navy-900;
}
.body { padding: 18px 26px 26px; }
.narrowBody { padding: 0; }
.text {
font-size: 13px;
color: $gray-500;
margin-bottom: 24px;
line-height: 1.6;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 26px 22px;
}
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
@@ -0,0 +1,25 @@
import * as React from 'react';
import styles from './Modal.module.scss';
import { IconButton } from './IconButton';
import { texts } from '../../texts';
export interface IModalProps {
title: string;
onClose: () => void;
footer?: React.ReactNode;
children: React.ReactNode;
}
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.box}>
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<IconButton icon="close" label={texts.common.close} onClick={onClose} />
</div>
<div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
</div>
);
@@ -0,0 +1,22 @@
import * as React from 'react';
import styles from './Card.module.scss';
import { Icon, IconName } from './Icon';
export interface INavCardProps {
icon: IconName;
title: string;
description: string;
onClick: () => void;
}
/** Карточка-ссылка на раздел админки. */
export const NavCard: React.FC<INavCardProps> = ({ icon, title, description, onClick }) => (
<a className={styles.navCard} onClick={e => { e.preventDefault(); onClick(); }} href="#">
<div className={styles.navIcon}><Icon name={icon} /></div>
<div>
<div className={styles.navTitle}>{title}</div>
<div className={styles.navDesc}>{description}</div>
</div>
<div className={styles.navArrow}><Icon name="chevron-right" size="sm" /></div>
</a>
);
@@ -0,0 +1,88 @@
@import '../../../../styles/design-tokens';
.bread {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: $gray-400;
margin-bottom: 14px;
letter-spacing: .2px;
flex-wrap: wrap;
}
.breadLink {
color: $navy-700;
text-decoration: none;
background: none;
border: 0;
padding: 0;
font: inherit;
cursor: pointer;
&:hover { color: $gold-600; }
}
.sep { color: $gold-400; font-size: 10px; }
.ph {
@include page-header;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: $sh-2;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 14px;
&::after {
content: '';
position: absolute;
right: -60px;
top: -60px;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(212,160,30,.14), transparent 65%);
}
}
.phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
.phIcon {
width: 46px;
height: 46px;
border-radius: $r-md;
background: rgba(212,160,30,.14);
border: 1px solid rgba(212,160,30,.3);
color: $gold-300;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.phSub {
font-size: 10.5px;
color: rgba(255,255,255,.55);
text-transform: uppercase;
letter-spacing: 1.8px;
font-weight: 500;
}
.phTitle {
font-family: $font-serif;
font-size: $fs-page-title;
font-weight: 600;
color: #fff;
margin-top: 3px;
letter-spacing: .2px;
}
.phRight {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
position: relative;
z-index: 1;
}
@@ -0,0 +1,24 @@
import * as React from 'react';
import styles from './PageChrome.module.scss';
import { Icon, IconName } from './Icon';
export interface IPageHeaderProps {
icon: IconName;
subtitle: string;
title: string;
/** Кнопки действий страницы — выравниваются по правому краю заголовка. */
actions?: React.ReactNode;
}
export const PageHeader: React.FC<IPageHeaderProps> = ({ icon, subtitle, title, actions }) => (
<div className={styles.ph}>
<div className={styles.phLeft}>
<div className={styles.phIcon}><Icon name={icon} /></div>
<div>
<div className={styles.phSub}>{subtitle}</div>
<div className={styles.phTitle}>{title}</div>
</div>
</div>
{actions && <div className={styles.phRight}>{actions}</div>}
</div>
);
@@ -0,0 +1,10 @@
@import '../../../../styles/design-tokens';
.panel {
background: #fff;
border-radius: $r-lg;
border: 1px solid $gray-200;
box-shadow: $sh-1;
padding: 26px;
margin-bottom: 24px;
}
@@ -0,0 +1,11 @@
import * as React from 'react';
import styles from './Panel.module.scss';
export interface IPanelProps {
children: React.ReactNode;
}
/** Белая карточка-контейнер для блоков на странице (например, формы фильтров). */
export const Panel: React.FC<IPanelProps> = ({ children }) => (
<div className={styles.panel}>{children}</div>
);
@@ -0,0 +1,7 @@
import * as React from 'react';
import styles from './DataTable.module.scss';
/** Основное (выделенное) значение ячейки таблицы. */
export const PrimaryCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span className={styles.primaryCell}>{children}</span>
);
@@ -0,0 +1,40 @@
import * as React from 'react';
import styles from './FormField.module.scss';
export interface IRadioOption<T extends string> {
value: T;
label: string;
disabled?: boolean;
disabledHint?: string;
}
export interface IRadioGroupProps<T extends string> {
name: string;
value: T;
options: IRadioOption<T>[];
onChange: (value: T) => void;
}
export function RadioGroup<T extends string>({ name, value, options, onChange }: IRadioGroupProps<T>): React.ReactElement {
return (
<div className={styles.radioGroup}>
{options.map(opt => (
<label
key={opt.value}
className={`${styles.radioRow} ${opt.disabled ? styles.disabled : ''}`}
title={opt.disabled ? opt.disabledHint : undefined}
>
<input
type="radio"
className={styles.radio}
name={name}
checked={value === opt.value}
disabled={opt.disabled}
onChange={() => onChange(opt.value)}
/>
{opt.label}
</label>
))}
</div>
);
}
@@ -0,0 +1,133 @@
@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;
}
.errorText {
padding: 10px 12px;
color: $red-500;
font-size: 12px;
text-align: center;
}
.sentinel {
height: 30px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: $gray-400;
font-size: 11.5px;
}
.clear {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid $gray-100;
flex-shrink: 0;
}
@@ -0,0 +1,154 @@
import * as React from 'react';
import styles from './SearchableSelect.module.scss';
import { Icon } from './Icon';
import { texts } from '../../texts';
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;
/** Текст, когда список опций пуст (не результат поиска, а вообще нечего выбирать). */
emptyMessage?: string;
}
export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
options,
value,
onChange,
placeholder = texts.select.placeholder,
searchPlaceholder = texts.select.searchPlaceholder,
disabled,
loading,
allowClear,
clearLabel = texts.select.notSelected,
emptyMessage = texts.select.nothingFound
}) => {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const rootRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
// или она не проходит текущий фильтр). Показать в этом случае плейсхолдер —
// значит соврать, будто поле пустое, и затереть значение при сохранении.
// Поэтому подмешиваем недостающую опцию явным образом.
const effectiveOptions = React.useMemo(() => {
if (!value || options.some(o => o.value === value)) return options;
return [{ value, label: value, sublabel: texts.select.missingInDictionary }, ...options];
}, [options, value]);
const selected = effectiveOptions.filter(o => o.value === value)[0];
// Поиск на клиенте: справочник целиком уже загружен, отделов ~700 —
// фильтровать в памяти дешевле, чем ходить на сервер на каждый символ.
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return effectiveOptions;
return effectiveOptions.filter(o =>
o.label.toLowerCase().indexOf(q) !== -1 ||
(o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false)
);
}, [effectiveOptions, 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 ? texts.select.loadingDictionary : (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}>
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
</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>
);
};
@@ -0,0 +1,21 @@
import * as React from 'react';
import styles from './FormField.module.scss';
export interface ISelectOption {
value: string;
label: string;
}
export interface ISelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
options: ISelectOption[];
placeholder?: string;
}
export const Select: React.FC<ISelectProps> = ({ options, placeholder, ...rest }) => (
<select {...rest} className={styles.select}>
{placeholder && <option value="">{placeholder}</option>}
{options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
@@ -0,0 +1,14 @@
import * as React from 'react';
import styles from './Card.module.scss';
export interface IStatCardProps {
label: string;
value: React.ReactNode;
}
export const StatCard: React.FC<IStatCardProps> = ({ label, value }) => (
<div className={styles.statCard}>
<div className={styles.statLabel}>{label}</div>
<div className={styles.statValue}>{value}</div>
</div>
);
@@ -0,0 +1,11 @@
import * as React from 'react';
import styles from './Card.module.scss';
export interface IStatRowProps {
children: React.ReactNode;
}
/** Строка счётчиков в верхней части дашборда. */
export const StatRow: React.FC<IStatRowProps> = ({ children }) => (
<div className={styles.statRow}>{children}</div>
);
@@ -0,0 +1,6 @@
import * as React from 'react';
import styles from './FormField.module.scss';
export const TextInput: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = props => (
<input {...props} className={styles.input} />
);
@@ -0,0 +1,6 @@
import * as React from 'react';
import styles from './FormField.module.scss';
export const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = props => (
<textarea {...props} className={styles.textarea} />
);
@@ -0,0 +1,28 @@
export * from './Icon';
export * from './Button';
export * from './IconButton';
export * from './Badge';
export * from './Alert';
export * from './Panel';
export * from './Breadcrumbs';
export * from './PageHeader';
export * from './CardGrid';
export * from './NavCard';
export * from './StatRow';
export * from './StatCard';
export * from './DataTable';
export * from './PrimaryCell';
export * from './ActionsCell';
export * from './CellNote';
export * from './Modal';
export * from './ConfirmDialog';
export * from './FormModal';
export * from './FormGrid';
export * from './Field';
export * from './TextInput';
export * from './Textarea';
export * from './Select';
export * from './Checkbox';
export * from './RadioGroup';
export * from './SearchableSelect';
export * from './AsyncSearchableSelect';
@@ -0,0 +1,34 @@
import * as React from 'react';
import { ApiClientError } from '../services/httpClient';
export interface AsyncList<T> {
items: T[];
isLoading: boolean;
error?: string;
reload: () => void;
}
/**
* Загрузка списка с бэкенда: держит данные, признак загрузки и текст ошибки
* в одном месте, чтобы экраны не повторяли этот код.
*
* load обязан быть стабильным (React.useCallback) — иначе загрузка пойдёт по кругу.
*/
export function useAsyncList<T>(load: () => Promise<T[]>): AsyncList<T> {
const [items, setItems] = React.useState<T[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | undefined>();
const reload = React.useCallback(() => {
setIsLoading(true);
setError(undefined);
load()
.then(setItems)
.catch((e: ApiClientError) => setError(e.message))
.finally(() => setIsLoading(false));
}, [load]);
React.useEffect(() => { reload(); }, [reload]);
return { items, isLoading, error, reload };
}
@@ -0,0 +1,30 @@
import * as React from 'react';
import { ApiClientError } from '../services/httpClient';
export interface AsyncSubmit {
isBusy: boolean;
error?: string;
/** Показать ошибку без запроса — например, результат валидации формы. */
fail: (message: string) => void;
run: (action: () => Promise<void>) => void;
}
/**
* Состояние вокруг одного пишущего запроса: «выполняется» + текст ошибки.
* Используется формами и подтверждением удаления, чтобы не дублировать
* setBusy/setError/finally в каждом экране.
*/
export function useAsyncSubmit(): AsyncSubmit {
const [isBusy, setIsBusy] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
const run = (action: () => Promise<void>): void => {
setIsBusy(true);
setError(undefined);
action()
.catch((e: ApiClientError) => setError(e.message))
.finally(() => setIsBusy(false));
};
return { isBusy, error, fail: setError, run };
}
@@ -0,0 +1,40 @@
import * as React from 'react';
import { AsyncSubmit, useAsyncSubmit } from './useAsyncSubmit';
export interface DeleteTarget<T> {
/** Запись, для которой открыт диалог подтверждения. */
target?: T;
isBusy: boolean;
error?: string;
ask: (item: T) => void;
cancel: () => void;
confirm: () => void;
}
/**
* Сценарий «спросили — удалили — обновили список», одинаковый на всех экранах:
* какая запись удаляется, идёт ли запрос и что показать при ошибке.
*/
export function useDeleteTarget<T>(remove: (item: T) => Promise<void>, onDeleted: () => void): DeleteTarget<T> {
const [target, setTarget] = React.useState<T | undefined>();
const submit: AsyncSubmit = useAsyncSubmit();
const confirm = (): void => {
if (!target) return;
submit.run(() =>
remove(target).then(() => {
setTarget(undefined);
onDeleted();
})
);
};
return {
target,
isBusy: submit.isBusy,
error: submit.error,
ask: setTarget,
cancel: () => setTarget(undefined),
confirm
};
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Company, Department } from '../models';
import { ApiClientError } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary';
export interface PortalDictionaries {
companies: Company[];
departments: Department[];
isLoading: boolean;
error?: string;
}
/**
* Компании и отделы из 1С грузим один раз на экран: они статичны, а отделов ~700 —
* искать по ним дешевле в памяти, чем ходить на сервер на каждый символ.
*/
export function usePortalDictionaries(dictionary?: PortalDictionaryClient): PortalDictionaries {
const [companies, setCompanies] = React.useState<Company[]>([]);
const [departments, setDepartments] = React.useState<Department[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
// Клиент появляется асинхронно: SPFx сначала выдаёт AAD-токен для api.tnsu.kz.
if (!dictionary) return;
setIsLoading(true);
setError(undefined);
Promise.all([dictionary.companies(), dictionary.departments()])
.then(([loadedCompanies, loadedDepartments]) => {
setCompanies(loadedCompanies);
setDepartments(loadedDepartments);
})
.catch((e: ApiClientError) => setError(e.message))
.finally(() => setIsLoading(false));
}, [dictionary]);
return { companies, departments, isLoading, error };
}
@@ -0,0 +1,34 @@
import * as React from 'react';
import { Employee } from '../models';
import { ApiClientError } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary';
export interface PortalEmployees {
items: Employee[];
isLoading: boolean;
error?: string;
}
/**
* Справочник сотрудников api.tnsu.kz — грузим один раз на экран, чтобы искать
* исполнителя/инициатора по ФИО или email на клиенте, без запроса на каждый символ.
*/
export function usePortalEmployees(dictionary?: PortalDictionaryClient): PortalEmployees {
const [items, setItems] = React.useState<Employee[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
// Клиент появляется асинхронно: SPFx сначала выдаёт AAD-токен для api.tnsu.kz.
if (!dictionary) return;
setIsLoading(true);
setError(undefined);
dictionary.employees()
.then(setItems)
.catch((e: ApiClientError) => setError(e.message))
.finally(() => setIsLoading(false));
}, [dictionary]);
return { items, isLoading, error };
}
@@ -0,0 +1,10 @@
import * as React from 'react';
import { RequestType } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
/** Справочник типов заявок — нужен и своему экрану, и селектам на других. */
export function useRequestTypes(api: ApprovalRouteApi): AsyncList<RequestType> {
const load = React.useCallback(() => api.requestTypes.list(), [api]);
return useAsyncList(load);
}
@@ -0,0 +1,27 @@
import * as React from 'react';
import { ResolveTemplateRouteQuery, ResolveTemplateRouteResult } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { useAsyncSubmit } from './useAsyncSubmit';
export interface ResolveRoute {
result?: ResolveTemplateRouteResult;
isBusy: boolean;
error?: string;
resolve: (query: ResolveTemplateRouteQuery) => void;
fail: (message: string) => void;
}
/** Диагностический запрос «как разрешится маршрут» и его состояние. */
export function useResolveRoute(api: ApprovalRouteApi): ResolveRoute {
const [result, setResult] = React.useState<ResolveTemplateRouteResult | undefined>();
const submit = useAsyncSubmit();
const resolve = (query: ResolveTemplateRouteQuery): void => {
// Прошлый результат убираем сразу: показывать его рядом с новыми
// параметрами формы — вводить пользователя в заблуждение.
setResult(undefined);
submit.run(() => api.templateRoutes.resolve(query).then(setResult));
};
return { result, isBusy: submit.isBusy, error: submit.error, resolve, fail: submit.fail };
}
@@ -0,0 +1,17 @@
import * as React from 'react';
import { TemplateRouteStep } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
function byOrderNo(steps: TemplateRouteStep[]): TemplateRouteStep[] {
return [...steps].sort((a, b) => a.orderNo - b.orderNo);
}
/** Шаги шаблона маршрута, всегда в порядке выполнения. */
export function useRouteSteps(api: ApprovalRouteApi, routeId: number): AsyncList<TemplateRouteStep> {
const load = React.useCallback(
() => api.templateRouteSteps.list(routeId).then(byOrderNo),
[api, routeId]
);
return useAsyncList(load);
}
@@ -0,0 +1,10 @@
import * as React from 'react';
import { TemplateRoute } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
/** Список маршрутов, при необходимости отфильтрованный по типу заявки. */
export function useTemplateRoutes(api: ApprovalRouteApi, requestTypeId?: number): AsyncList<TemplateRoute> {
const load = React.useCallback(() => api.templateRoutes.list(requestTypeId), [api, requestTypeId]);
return useAsyncList(load);
}
@@ -0,0 +1,16 @@
define([], function() {
return {
"PropertyPaneDescription": "Description",
"BasicGroupName": "Group Name",
"DescriptionFieldLabel": "Description Field",
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
"AppLocalEnvironmentOutlook": "The app is running on your local environment in Outlook",
"AppSharePointEnvironment": "The app is running on SharePoint page",
"AppTeamsTabEnvironment": "The app is running in Microsoft Teams",
"AppOfficeEnvironment": "The app is running in office.com",
"AppOutlookEnvironment": "The app is running in Outlook",
"UnknownEnvironment": "The app is running in an unknown environment"
}
});
+19
View File
@@ -0,0 +1,19 @@
declare interface IApprovalAdminWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
AppLocalEnvironmentSharePoint: string;
AppLocalEnvironmentTeams: string;
AppLocalEnvironmentOffice: string;
AppLocalEnvironmentOutlook: string;
AppSharePointEnvironment: string;
AppTeamsTabEnvironment: string;
AppOfficeEnvironment: string;
AppOutlookEnvironment: string;
UnknownEnvironment: string;
}
declare module 'ApprovalAdminWebPartStrings' {
const strings: IApprovalAdminWebPartStrings;
export = strings;
}
@@ -0,0 +1,31 @@
// Справочники api.tnsu.kz (компании, отделы, проекты) в виде, удобном для UI.
// Приведение из «сырых» PascalCase-ответов портала — в services/portalDictionary.ts.
export interface Company {
id: string;
name: string;
}
export interface Department {
id: string;
name: string;
companyId: string;
company: string;
}
// Важно: идентификатор проекта — поле "oid". Именно оно принимается
// портальным /Project/{oid:guid} и передаётся в ResolveTemplateRouteQuery.projectId.
// Числовое "id" и legacy "id1c" — другие идентификаторы, для резолва не подходят.
export interface Project {
oid: string;
name: string;
code: string | null;
status: string | null;
}
// api.tnsu.kz/Employee/All. Ключ для полей executorEmail/userEmail на нашем
// бэкенде — email, но вводить его руками неудобно, поэтому в UI ищем по ФИО.
export interface Employee {
fio: string;
email: string;
}
@@ -0,0 +1,30 @@
// Зеркало enum'ов backend (Tnsu.ApprovalRoute.Domain/Enums/*.cs).
// Значения должны 1:1 соответствовать C#-стороне: они ходят по сети как строки.
// Человекочитаемые подписи к этим значениям живут не здесь, а в texts.ts.
export enum RouteType {
None = 'None',
Parallel = 'Parallel',
Sequential = 'Sequential'
}
export enum RouteStepStatus {
None = 'None',
Approval = 'Approval',
Execution = 'Execution'
}
export enum ProjectRole {
None = 'None',
ProjectManager = 'ProjectManager',
Planner = 'Planner',
ManagerHSE = 'ManagerHSE',
Security = 'Security'
}
export enum HeadType {
None = 'None',
Chief = 'Chief',
DirectionHead = 'DirectionHead',
DirectorTansu = 'DirectorTansu'
}
@@ -0,0 +1,7 @@
// Единая точка импорта моделей: import { TemplateRoute } from '../../models';
export * from './enums';
export * from './dictionaries';
export * from './requestTypes';
export * from './templateRoutes';
export * from './templateRouteSteps';
export * from './resolve';
@@ -0,0 +1,19 @@
// DTO эндпоинта api/request-types (Application/RequestTypes/*.cs).
export interface RequestType {
id: number;
name: string;
pageUrl: string;
}
export interface CreateRequestTypeInput {
name: string;
pageUrl: string;
}
export type UpdateRequestTypeInput = CreateRequestTypeInput;
export interface PatchRequestTypeInput {
name?: string;
pageUrl?: string;
}
@@ -0,0 +1,28 @@
// DTO эндпоинта api/template-routes/resolve
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
import { RouteStepStatus, RouteType } from './enums';
export interface ResolveTemplateRouteQuery {
requestTypeId: number;
userEmail?: string;
projectId?: string;
status: RouteStepStatus;
}
export interface ResolvedRouteStep {
id: number;
orderNo: number;
executorEmail: string | null;
position: string | null;
allowReassignToOtherApprovers: boolean;
/** Причина, по которой исполнитель шага не определился (текст с бэкенда). */
warning: string | null;
}
export interface ResolveTemplateRouteResult {
routeId: number;
routeType: RouteType;
requiresControl: boolean;
steps: ResolvedRouteStep[];
}
@@ -0,0 +1,45 @@
// DTO эндпоинта api/template-routes/{routeId}/steps
// (Application/TemplateRouteSteps/*.cs).
import { HeadType, ProjectRole, RouteStepStatus } from './enums';
export interface TemplateRouteStep {
id: number;
orderNo: number;
executorEmail: string | null;
position: string | null;
projectRole: ProjectRole | null;
headType: HeadType | null;
status: RouteStepStatus;
routeId: number;
allowReassignToOtherApprovers: boolean;
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
warning?: string | null;
}
export interface CreateTemplateRouteStepInput {
orderNo: number;
executorEmail: string | null;
position: string | null;
projectRole: ProjectRole | null;
headType: HeadType | null;
status: RouteStepStatus;
allowReassignToOtherApprovers: boolean;
}
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
export interface PatchTemplateRouteStepInput {
orderNo?: number;
executorEmail?: string | null;
position?: string | null;
projectRole?: ProjectRole | null;
headType?: HeadType | null;
status?: RouteStepStatus;
allowReassignToOtherApprovers?: boolean;
}
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
// (см. TemplateRouteStepFields.CountFilled на бэкенде). Union-тип, а не строки
// по месту, — чтобы опечатку поймал компилятор.
export type StepExecutorKind = 'email' | 'position' | 'projectRole' | 'headType';
@@ -0,0 +1,39 @@
// DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs).
import { RouteType } from './enums';
export interface TemplateRoute {
id: number;
routeType: RouteType;
requestTypeId: number;
departmentId: string | null;
companyId: string;
byProject: boolean;
requiresControl: boolean;
}
export interface CreateTemplateRouteInput {
routeType: RouteType;
requestTypeId: number;
departmentId: string | null;
companyId: string;
byProject: boolean;
requiresControl: boolean;
}
// Тип заявки у существующего маршрута не меняется — его нет в update-контракте.
export interface UpdateTemplateRouteInput {
routeType: RouteType;
departmentId: string | null;
companyId: string;
byProject: boolean;
requiresControl: boolean;
}
export interface PatchTemplateRouteInput {
routeType?: RouteType;
departmentId?: string | null;
companyId?: string;
byProject?: boolean;
requiresControl?: boolean;
}
@@ -0,0 +1,87 @@
import { IHttpClient } from './httpClient';
import {
CreateRequestTypeInput,
CreateTemplateRouteInput,
CreateTemplateRouteStepInput,
PatchRequestTypeInput,
PatchTemplateRouteInput,
PatchTemplateRouteStepInput,
RequestType,
ResolveTemplateRouteQuery,
ResolveTemplateRouteResult,
TemplateRoute,
TemplateRouteStep,
UpdateRequestTypeInput,
UpdateTemplateRouteInput,
UpdateTemplateRouteStepInput
} from '../models';
// Тонкий 1:1 фасад над контроллерами бэкенда:
// backend/src/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
export class ApprovalRouteApi {
constructor(private readonly http: IHttpClient) {}
public readonly requestTypes = {
list: (): Promise<RequestType[]> =>
this.http.get<RequestType[]>('/api/request-types'),
get: (id: number): Promise<RequestType> =>
this.http.get<RequestType>(`/api/request-types/${id}`),
create: (body: CreateRequestTypeInput): Promise<RequestType> =>
this.http.post<RequestType>('/api/request-types', body),
update: (id: number, body: UpdateRequestTypeInput): Promise<RequestType> =>
this.http.put<RequestType>(`/api/request-types/${id}`, body),
patch: (id: number, body: PatchRequestTypeInput): Promise<RequestType> =>
this.http.patch<RequestType>(`/api/request-types/${id}`, body),
remove: (id: number): Promise<void> =>
this.http.delete(`/api/request-types/${id}`)
};
public readonly templateRoutes = {
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
get: (id: number): Promise<TemplateRoute> =>
this.http.get<TemplateRoute>(`/api/template-routes/${id}`),
create: (body: CreateTemplateRouteInput): Promise<TemplateRoute> =>
this.http.post<TemplateRoute>('/api/template-routes', body),
update: (id: number, body: UpdateTemplateRouteInput): Promise<TemplateRoute> =>
this.http.put<TemplateRoute>(`/api/template-routes/${id}`, body),
patch: (id: number, body: PatchTemplateRouteInput): Promise<TemplateRoute> =>
this.http.patch<TemplateRoute>(`/api/template-routes/${id}`, body),
remove: (id: number): Promise<void> =>
this.http.delete(`/api/template-routes/${id}`),
resolve: (query: ResolveTemplateRouteQuery): Promise<ResolveTemplateRouteResult> =>
this.http.get<ResolveTemplateRouteResult>('/api/template-routes/resolve', { ...query })
};
public readonly templateRouteSteps = {
list: (routeId: number): Promise<TemplateRouteStep[]> =>
this.http.get<TemplateRouteStep[]>(`/api/template-routes/${routeId}/steps`),
get: (routeId: number, id: number): Promise<TemplateRouteStep> =>
this.http.get<TemplateRouteStep>(`/api/template-routes/${routeId}/steps/${id}`),
create: (routeId: number, body: CreateTemplateRouteStepInput): Promise<TemplateRouteStep> =>
this.http.post<TemplateRouteStep>(`/api/template-routes/${routeId}/steps`, body),
update: (routeId: number, id: number, body: UpdateTemplateRouteStepInput): Promise<TemplateRouteStep> =>
this.http.put<TemplateRouteStep>(`/api/template-routes/${routeId}/steps/${id}`, body),
patch: (routeId: number, id: number, body: PatchTemplateRouteStepInput): Promise<TemplateRouteStep> =>
this.http.patch<TemplateRouteStep>(`/api/template-routes/${routeId}/steps/${id}`, body),
remove: (routeId: number, id: number): Promise<void> =>
this.http.delete(`/api/template-routes/${routeId}/steps/${id}`)
};
}
@@ -0,0 +1,160 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
// Ответ бэкенда всегда в конверте { data: T } на успехе (Api/Common/ApiResponse.cs)
// и { messages: string[] } на ошибке (ApiError). 204 No Content возвращается без тела.
interface IApiEnvelope<T> {
data?: T;
messages?: string[];
}
export class ApiClientError extends Error {
public readonly status: number;
public readonly messages: string[];
constructor(status: number, messages: string[]) {
super(messages.join('; ') || `HTTP ${status}`);
this.status = status;
this.messages = messages;
}
}
export type QueryParams = Record<string, string | number | boolean | undefined | null>;
export interface IHttpClient {
get<T>(path: string, query?: QueryParams): Promise<T>;
post<T>(path: string, body?: unknown): Promise<T>;
put<T>(path: string, body?: unknown): Promise<T>;
patch<T>(path: string, body?: unknown): Promise<T>;
delete(path: string): Promise<void>;
}
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
const url = new URL(path.replace(/^\//, ''), normalizedBase);
if (query) {
Object.keys(query).forEach(key => {
const value = query[key];
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
}
return url.toString();
}
interface IMinimalResponse {
ok: boolean;
status: number;
text(): Promise<string>;
}
async function parseResponse<T>(response: IMinimalResponse): Promise<T> {
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
let json: IApiEnvelope<T> | undefined;
try {
json = text ? JSON.parse(text) : undefined;
} catch {
// Ответ пришёл не в конверте ApiResponse/ApiError (например, "сырая" страница
// необработанного исключения ASP.NET Core или ошибка от прокси перед API).
if (!response.ok) {
throw new ApiClientError(response.status, [text || `HTTP ${response.status}`]);
}
throw new ApiClientError(response.status, ['Сервер вернул не-JSON ответ.']);
}
if (!response.ok) {
const messages = json?.messages ?? [`HTTP ${response.status}`];
throw new ApiClientError(response.status, messages);
}
return (json?.data as T) ?? (undefined as T);
}
/**
* Прод/staging: вызовы через AAD-защищённое SPFx-подключение (AadHttpClient).
* Требует webApiPermissionRequests в package-solution.json и админского
* согласия в SharePoint admin center → Advanced → API access.
*/
export class AadApiHttpClient implements IHttpClient {
constructor(private readonly client: AadHttpClient, private readonly baseUrl: string) {}
public get<T>(path: string, query?: QueryParams): Promise<T> {
const url = buildUrl(this.baseUrl, path, query);
return this.client
.get(url, AadHttpClient.configurations.v1)
.then((r: HttpClientResponse) => parseResponse<T>(r));
}
public post<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('POST', path, body);
}
public put<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('PUT', path, body);
}
public patch<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('PATCH', path, body);
}
public async delete(path: string): Promise<void> {
await this.send<void>('DELETE', path);
}
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
const url = buildUrl(this.baseUrl, path);
return this.client
.fetch(url, AadHttpClient.configurations.v1, {
method,
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined
})
.then((r: HttpClientResponse) => parseResponse<T>(r));
}
}
/**
* Локальная отладка: обычный fetch с заголовком X-Dev-User-Email.
* Работает только если бэкенд поднят с пустыми Entra:TenantId/Entra:Audience
* (см. backend/.../Auth/AuthSetup.cs — тогда включается DevAuthHandler).
*/
export class DevApiHttpClient implements IHttpClient {
constructor(private readonly baseUrl: string, private readonly devUserEmail: string) {}
public get<T>(path: string, query?: QueryParams): Promise<T> {
return this.send<T>('GET', path, undefined, query);
}
public post<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('POST', path, body);
}
public put<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('PUT', path, body);
}
public patch<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('PATCH', path, body);
}
public async delete(path: string): Promise<void> {
await this.send<void>('DELETE', path);
}
private async send<T>(method: string, path: string, body?: unknown, query?: QueryParams): Promise<T> {
const url = buildUrl(this.baseUrl, path, query);
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-Dev-User-Email': this.devUserEmail
},
body: body !== undefined ? JSON.stringify(body) : undefined
});
return parseResponse<T>(response);
}
}
@@ -0,0 +1,114 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { Company, Department, Employee, Project } 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;
}
// 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;
}
// GET /Employee/All — уже camelCase, как /Project (не PascalCase, как /Dictionary/*).
// Строка на каждого сотрудника в каждом его отделе, поэтому один email может
// повторяться несколько раз; сотрудники без email в подборе исполнителя бесполезны.
interface IPortalEmployee {
fio: string;
email: string | null;
}
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
}));
}
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
};
}
public async employees(): Promise<Employee[]> {
const raw = await getJson<IPortalEmployee[]>(this.client, this.baseUrl, '/Employee/All');
const byEmail = new Map<string, Employee>();
(raw ?? []).forEach(x => {
if (x.email && !byEmail.has(x.email)) byEmail.set(x.email, { fio: x.fio, email: x.email });
});
return Array.from(byEmail.values());
}
}
@@ -0,0 +1,244 @@
// Все пользовательские тексты интерфейса — в одном месте.
// Так их видно целиком (легко вычитать формулировки) и это задел под локализацию:
// компонентам не нужно знать, откуда пришла строка.
//
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
import { HeadType, ProjectRole, RouteStepStatus, RouteType } from './models';
import { pluralRu } from './utils/plural';
// ---- Подписи enum'ов ----
// Record<enum, string> гарантирует, что при добавлении значения в enum
// компилятор потребует и подпись к нему.
export const routeTypeLabels: Record<RouteType, string> = {
[RouteType.None]: 'Не задан',
[RouteType.Parallel]: 'Параллельный',
[RouteType.Sequential]: 'Последовательный'
};
export const routeStepStatusLabels: Record<RouteStepStatus, string> = {
[RouteStepStatus.None]: 'Не задан',
[RouteStepStatus.Approval]: 'Согласование',
[RouteStepStatus.Execution]: 'Исполнение'
};
export const projectRoleLabels: Record<ProjectRole, string> = {
[ProjectRole.None]: '—',
[ProjectRole.ProjectManager]: 'Руководитель проекта',
[ProjectRole.Planner]: 'Планировщик',
[ProjectRole.ManagerHSE]: 'Менеджер HSE',
[ProjectRole.Security]: 'Безопасность'
};
export const headTypeLabels: Record<HeadType, string> = {
[HeadType.None]: '—',
[HeadType.Chief]: 'Непосредственный руководитель',
[HeadType.DirectionHead]: 'Руководитель направления',
[HeadType.DirectorTansu]: 'Директор Tansu'
};
export const texts = {
common: {
home: 'Главная',
cancel: 'Отмена',
save: 'Сохранить',
saving: 'Сохранение...',
remove: 'Удалить',
edit: 'Редактировать',
close: 'Закрыть',
loading: 'Загрузка...',
yes: 'Да',
no: 'Нет',
/** Прочерк вместо пустого значения — чтобы ячейка таблицы не выглядела сломанной. */
empty: '—'
},
nav: {
/** Буква в золотом квадрате логотипа — не зависит от заголовка панели. */
markLetter: 'T',
title: 'Панель настройки маршрутов',
dashboard: 'Дашборд',
requestTypes: 'Типы заявок',
routes: 'Маршруты',
resolve: 'Проверка маршрута'
},
app: {
connecting: 'Подключение к API...',
connectFailed: (message: string): string => `Не удалось подключиться к API: ${message}`,
connectHint: 'Проверьте свойства веб-части (Api Base Url, режим авторизации) в панели настроек.'
},
dashboard: {
requestTypesStat: 'Типов заявок',
routesStat: 'Маршрутов согласования',
sections: 'Разделы',
requestTypesCard: {
title: 'Типы заявок',
description: 'Справочник типов заявок и их страниц'
},
routesCard: {
title: 'Маршруты согласования',
description: 'Маршруты, шаги и исполнители'
},
resolveCard: {
title: 'Проверка маршрута',
description: 'Как разрешится маршрут для конкретного пользователя'
}
},
requestTypes: {
breadcrumb: 'Типы заявок',
subtitle: 'Справочник',
title: 'Типы заявок',
add: 'Добавить тип',
emptyList: 'Типов заявок пока нет.',
columnName: 'Название',
columnPageUrl: 'Url страницы',
createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки',
nameLabel: 'Название',
namePlaceholder: 'Например, Заявка на командировку',
pageUrlLabel: 'Url страницы',
pageUrlHint: 'Страница формы заявки на портале.',
pageUrlPlaceholder: '/pages/business-trip',
validationRequired: 'Заполните имя и Url страницы.',
deleteTitle: 'Удалить тип заявки?',
deleteText: (name: string): string =>
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
},
routes: {
breadcrumb: 'Маршруты согласования',
subtitle: 'Настройка согласований',
title: 'Маршруты согласования',
add: 'Добавить маршрут',
filterLabel: 'Фильтр по типу заявки',
filterAll: 'Все типы заявок',
dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`,
emptyList: 'Маршрутов пока нет.',
columnRequestType: 'Тип заявки',
columnRouteType: 'Тип маршрута',
columnCompany: 'Компания',
columnDepartment: 'Отдел',
columnByProject: 'По проекту',
columnControl: 'Контроль',
controlRequired: 'Требуется',
openSteps: 'Шаги',
createTitle: 'Новый маршрут',
editTitle: (routeId: number): string => `Маршрут #${routeId}`,
requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки',
routeTypeLabel: 'Тип маршрута',
companyLabel: 'Компания',
companyHint: 'Справочник 1С (api.tnsu.kz).',
companyPlaceholder: 'Выберите компанию',
companySearchPlaceholder: 'Поиск по названию компании...',
departmentLabel: 'Отдел',
departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.',
departmentHintNoCompany: 'Сначала выберите компанию.',
departmentPlaceholder: 'Без отдела (маршрут по умолчанию)',
departmentSearchPlaceholder: 'Поиск по названию отдела...',
departmentEmpty: 'У выбранной компании нет отделов в справочнике',
byProjectLabel: 'По проекту',
byProjectCheckbox: 'Маршрут учитывает конкретный проект',
controlLabel: 'Контроль',
controlCheckbox: 'Требуется отдельный контроль исполнения',
validationRequestType: 'Выберите тип заявки.',
validationCompany: 'Компания обязательна (Guid из справочника api.tnsu.kz).',
deleteTitle: 'Удалить маршрут?',
deleteText: (routeId: number): string =>
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`
},
routeSteps: {
breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`,
title: (routeId: number): string => `Маршрут #${routeId}`,
subtitle: (requestTypeName: string, routeTypeLabel: string): string =>
`${requestTypeName} · ${routeTypeLabel}`,
back: 'К маршрутам',
add: 'Добавить шаг',
emptyList: 'Шагов пока нет — добавьте первый.',
columnOrderNo: '№',
columnExecutor: 'Исполнитель',
columnStatus: 'Статус',
columnReassign: 'Переназначение',
reassignAllowed: 'Разрешено',
executorRole: (roleLabel: string): string => `Роль: ${roleLabel}`,
executorHead: (headLabel: string): string => `Руководитель: ${headLabel}`,
createTitle: 'Новый шаг маршрута',
editTitle: (orderNo: number): string => `Шаг #${orderNo}`,
orderNoLabel: 'Порядковый номер',
statusLabel: 'Статус шага',
kindLabel: 'Кто исполнитель',
kindHint: 'Должно быть заполнено ровно одно из полей.',
kindEmail: 'Конкретный сотрудник',
kindPosition: 'По должности в компании инициатора',
kindProjectRole: 'По роли в проекте',
kindProjectRoleDisabled: 'Недоступно: маршрут не учитывает проект (по проекту = нет).',
kindHeadType: 'По типу руководителя',
executorLabel: 'Сотрудник',
executorPlaceholder: 'Выберите сотрудника',
positionLabel: 'Должность',
positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.',
positionPlaceholder: 'Главный инженер',
projectRoleLabel: 'Роль в проекте',
headTypeLabel: 'Тип руководителя',
reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим',
validationOrderNo: 'Порядковый номер шага обязателен.',
validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.',
deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
},
resolve: {
breadcrumb: 'Проверка маршрута',
subtitle: 'Диагностика',
title: 'Проверка разрешения маршрута',
requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки',
userEmailLabel: 'Инициатор',
userEmailHint: (currentUserEmail: string): string => `Пусто = текущий пользователь (${currentUserEmail}).`,
userEmailHintUnknown: 'Пусто = текущий пользователь из токена.',
userEmailPlaceholder: 'Выберите сотрудника',
userEmailClear: 'Текущий пользователь',
projectLabel: 'Проект',
projectHint: 'Заполните, если маршрут зависит от проекта (ByProject = true).',
projectPlaceholder: 'Не выбран',
projectLoadingPlaceholder: 'Справочник загружается...',
projectSearchPlaceholder: 'Поиск проекта по названию...',
projectClear: 'Без проекта',
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
statusLabel: 'Статус',
run: 'Проверить',
running: 'Проверка...',
validationRequestType: 'Выберите тип заявки.',
summary: (routeId: number, routeTypeLabel: string, requiresControl: boolean): string =>
`Маршрут #${routeId} · ${routeTypeLabel}${requiresControl ? ' · требуется контроль исполнения' : ''}`,
unresolved: (count: number): string =>
`Исполнитель не определён: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} — причина указана в строке.`,
positionMissing: 'Должность не указана в карточке сотрудника.',
columnOrderNo: '№',
columnExecutor: 'Исполнитель',
columnPosition: 'Должность',
columnReassign: 'Переназначение',
reassignAllowed: 'Разрешено',
emptySteps: 'Маршрут разрешился без шагов для этого статуса.'
},
// Значения по умолчанию для переиспользуемых контролов выбора.
select: {
placeholder: 'Выберите значение',
searchPlaceholder: 'Поиск...',
nothingFound: 'Ничего не найдено',
notSelected: 'Не выбрано',
missingInDictionary: 'Нет в справочнике',
loadingDictionary: 'Загрузка справочника...',
searching: 'Поиск...',
employeeSearchPlaceholder: 'Поиск по ФИО или email...',
employeesEmpty: 'Сотрудники не найдены в справочнике'
}
};
@@ -0,0 +1,13 @@
import { Employee } from '../models';
import { ISearchableOption } from '../components/ui';
/** Опция для SearchableSelect: ФИО — основная строка, email — маленькая подпись под ней. */
export function toEmployeeOption(employee: Employee): ISearchableOption {
return { value: employee.email, label: employee.fio, sublabel: employee.email };
}
export function findEmployeeByEmail(employees: Employee[], email: string | null): Employee | undefined {
if (!email) return undefined;
const target = email.toLowerCase();
return employees.find(e => e.email.toLowerCase() === target);
}
@@ -0,0 +1,10 @@
const SHORT_GUID_LENGTH = 8;
/**
* Короткий вид Guid для таблиц: 3fa85f64… вместо всех 36 символов.
* Нужен, когда название из справочника ещё не загрузилось или запись удалили,
* а строка таблицы не должна выглядеть пустой.
*/
export function shortGuid(guid: string): string {
return guid.length > SHORT_GUID_LENGTH ? `${guid.slice(0, SHORT_GUID_LENGTH)}…` : guid;
}
@@ -0,0 +1,16 @@
/**
* Русское склонение существительного после числа.
*
* Формы передаются в порядке: 1 шаг / 2 шага / 5 шагов.
* Пример: pluralRu(2, ['шаг', 'шага', 'шагов']) → 'шага'.
*/
export function pluralRu(count: number, forms: [string, string, string]): string {
const lastTwoDigits = Math.abs(count) % 100;
const lastDigit = lastTwoDigits % 10;
// 11–14 — исключение: «11 шагов», хотя последняя цифра 1.
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) return forms[2];
if (lastDigit === 1) return forms[0];
if (lastDigit >= 2 && lastDigit <= 4) return forms[1];
return forms[2];
}
@@ -0,0 +1,38 @@
import { Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
import { headTypeLabels, projectRoleLabels, texts } from '../texts';
import { findEmployeeByEmail } from './employee';
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
export function detectExecutorKind(step: {
executorEmail?: string | null;
position?: string | null;
projectRole?: ProjectRole | null;
headType?: HeadType | null;
}): StepExecutorKind {
if (step.executorEmail) return 'email';
if (step.position) return 'position';
if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole';
return 'headType';
}
export interface StepExecutorDisplay {
primary: string;
/** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
secondary?: string;
}
/** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
if (step.executorEmail) {
const employee = findEmployeeByEmail(employees, step.executorEmail);
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
}
if (step.position) return { primary: step.position };
if (step.projectRole && step.projectRole !== ProjectRole.None) {
return { primary: texts.routeSteps.executorRole(projectRoleLabels[step.projectRole]) };
}
if (step.headType && step.headType !== HeadType.None) {
return { primary: texts.routeSteps.executorHead(headTypeLabels[step.headType]) };
}
return { primary: texts.common.empty };
}