переезд на панель

This commit is contained in:
gorbenko.pavel
2026-08-19 15:18:41 +05:00
parent 30fb3fa2b1
commit 101ca975e0
7 changed files with 135 additions and 210 deletions
+1
View File
@@ -0,0 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
+13
View File
@@ -17,6 +17,7 @@
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@tnsu/ui-kit": "^0.4.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -6281,6 +6282,18 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/ui-kit": {
"version": "0.4.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.4.0/ui-kit-0.4.0.tgz",
"integrity": "sha512-J3cBvoR2vV0SC5GihOagLAqvC0txQE342qIh6viabMt+Um6Jg4mhTff+ZkVahG0g+bF6sqSCUl3x813aO82KuQ==",
"license": "ISC",
"peerDependencies": {
"@fluentui/react": "^8.106.4",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"tslib": "^2.3.1"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
+2 -1
View File
@@ -13,7 +13,6 @@
}, },
"dependencies": { "dependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@pnp/sp": "^4.20.0",
"@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-component-base": "1.22.1",
"@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-core-library": "1.22.1",
"@microsoft/sp-http": "1.22.1", "@microsoft/sp-http": "1.22.1",
@@ -21,6 +20,8 @@
"@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@tnsu/ui-kit": "^0.4.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -1,23 +1,63 @@
@import '../../../../styles/design-tokens'; @import '../../../../styles/design-tokens';
// Раскладка — тот же боковой рейл + основная область, что и в request-portal
// (@tnsu/ui-kit Sidebar, см. RequestPortal.module.scss): единый стиль
// навигации во всех веб-частях Tnsu. В отличие от request-portal (рейл во всю
// высоту окна, edge-to-edge), здесь виджет — ограниченная карточка на странице
// SharePoint, поэтому без sticky/100vh-трюков: рейл просто растягивается по
// высоте своего соседа (.main) через align-items: stretch.
.shell { .shell {
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%); display: flex;
align-items: stretch;
border-radius: $r-lg; border-radius: $r-lg;
border: 1px solid $gray-200; border: 1px solid $gray-200;
overflow: hidden; overflow: hidden;
box-shadow: $sh-1; box-shadow: $sh-1;
color: $gray-900;
font-family: $font-sans; font-family: $font-sans;
font-size: $fs-body; font-size: $fs-body;
line-height: 1.55; line-height: 1.55;
} }
.side {
flex: 0 0 auto;
display: flex;
background: $navy-800;
}
.main {
flex: 1 1 auto;
min-width: 0; // чтобы контент не распирал колонку
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
color: $gray-900;
}
.content { .content {
padding: 28px 30px 36px; padding: 28px 30px 36px;
max-width: 1080px; max-width: 1080px;
margin: 0 auto; margin: 0 auto;
} }
// Мобильная кнопка-бургер (открывает выезжающий Sidebar). На десктопе скрыта.
.mobileNav {
display: none;
padding: $sp-4 $sp-6 0;
}
// < 768px: рейл сам уходит в position:fixed и уезжает за левый край экрана,
// пока не открыт (см. @tnsu/ui-kit Sidebar — это его собственный CSS, чисто
// media-query, без участия JS) — НЕ display:none на .side: рейл вёрстке всё
// равно нужен в потоке, чтобы при mobileOpen выехать поверх контента.
@media (max-width: 768px) {
.mobileNav {
display: block;
}
.side {
align-self: flex-start;
background: none;
}
}
.centerState { .centerState {
padding: 60px 30px; padding: 60px 30px;
text-align: center; text-align: center;
@@ -1,6 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import styles from './AdminApp.module.scss'; import styles from './AdminApp.module.scss';
import { TopNav } from './TopNav'; import '@tnsu/ui-kit/styles.css';
import { IconSprite, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
import type { ISidebarItem } from '@tnsu/ui-kit';
import { ScreenKey } from './types'; import { ScreenKey } from './types';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
@@ -8,6 +10,7 @@ import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestManageService } from '../../services/requestManageService'; import { RequestManageService } from '../../services/requestManageService';
import { RequestType, TemplateRoute } from '../../models'; import { RequestType, TemplateRoute } from '../../models';
import { navigate, useRoute } from '../../routing'; import { navigate, useRoute } from '../../routing';
import { texts } from '../../texts';
import { DashboardScreen } from '../screens/DashboardScreen'; import { DashboardScreen } from '../screens/DashboardScreen';
import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen';
import { RoutesScreen } from '../screens/RoutesScreen'; import { RoutesScreen } from '../screens/RoutesScreen';
@@ -15,6 +18,22 @@ import { RouteStepsScreen } from '../screens/RouteStepsScreen';
import { ResolveScreen } from '../screens/ResolveScreen'; import { ResolveScreen } from '../screens/ResolveScreen';
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen'; import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
// отдельная вкладка URL, а состояние вкладки 'routes' при открытых шагах
// конкретного маршрута (см. routing.ts) — подсвечиваем в этом случае 'routes'.
const NAV_ITEMS: ISidebarItem[] = [
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' }
];
function sidebarActiveId(tab: ScreenKey): string {
return tab === 'routeSteps' ? 'routes' : tab;
}
export interface IAdminAppProps { export interface IAdminAppProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
@@ -28,8 +47,13 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
const route = useRoute(); const route = useRoute();
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>(); const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>(); const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
const [navCollapsed, setNavCollapsed] = React.useState(false);
const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
const navigateTab = (key: ScreenKey): void => navigate({ tab: key }); const navigateTab = (key: ScreenKey): void => {
setMobileNavOpen(false);
navigate({ tab: key });
};
const openSteps = (r: TemplateRoute): void => { const openSteps = (r: TemplateRoute): void => {
setSelectedRoute(r); setSelectedRoute(r);
@@ -76,41 +100,58 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
return ( return (
<div className={styles.shell}> <div className={styles.shell}>
<TopNav active={route.tab} onSelect={navigateTab} /> <IconSprite />
<div className={styles.content}> <div className={styles.side}>
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />} <Sidebar
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />} items={NAV_ITEMS}
{route.tab === 'routes' && !route.routeId && ( activeId={sidebarActiveId(route.tab)}
<RoutesScreen onChange={id => navigateTab(id as ScreenKey)}
api={api} brand={texts.nav.title}
dictionary={dictionary} collapsed={navCollapsed}
initialFilterRequestTypeId={route.routesFilter} onToggleCollapsed={() => setNavCollapsed(v => !v)}
onOpenSteps={openSteps} mobileOpen={mobileNavOpen}
/> onMobileClose={() => setMobileNavOpen(false)}
)} />
{route.tab === 'routes' && route.routeId && selectedRoute && (
<RouteStepsScreen
api={api}
route={selectedRoute}
requestType={selectedRequestType}
dictionary={dictionary}
sharePoint={sharePoint}
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
/>
)}
{route.tab === 'resolve' && (
<ResolveScreen
api={api}
dictionary={dictionary}
sharePoint={sharePoint}
currentUserEmail={currentUserEmail}
route={route.resolve}
/>
)}
{route.tab === 'manageRequests' && (
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
)}
</div> </div>
<main className={styles.main}>
<div className={styles.mobileNav}>
<SidebarToggle onClick={() => setMobileNavOpen(true)} />
</div>
<div className={styles.content}>
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
{route.tab === 'routes' && !route.routeId && (
<RoutesScreen
api={api}
dictionary={dictionary}
initialFilterRequestTypeId={route.routesFilter}
onOpenSteps={openSteps}
/>
)}
{route.tab === 'routes' && route.routeId && selectedRoute && (
<RouteStepsScreen
api={api}
route={selectedRoute}
requestType={selectedRequestType}
dictionary={dictionary}
sharePoint={sharePoint}
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
/>
)}
{route.tab === 'resolve' && (
<ResolveScreen
api={api}
dictionary={dictionary}
sharePoint={sharePoint}
currentUserEmail={currentUserEmail}
route={route.resolve}
/>
)}
{route.tab === 'manageRequests' && (
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
)}
</div>
</main>
</div> </div>
); );
}; };
@@ -1,76 +0,0 @@
@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: 20px;
flex-shrink: 0;
}
.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;
// Тонкая полупрозрачная полоса — не спрятана полностью: с добавлением новых
// вкладок (сейчас их 6) они не всегда помещаются в узкую панель, и без
// видимого скроллбара непонятно, что часть вкладок обрезана, а не пропала.
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .35) transparent;
&::-webkit-scrollbar { height: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .35); border-radius: 2px; }
}
.tab {
flex-shrink: 0;
white-space: nowrap;
padding: 0 13px;
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; }
}
@@ -1,95 +0,0 @@
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 },
{ key: 'manageRequests', label: texts.nav.manageRequests }
];
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.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>
);
};