diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..8e18031 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a4f4b7..aeb2ac7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@microsoft/sp-property-pane": "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-dom": "17.0.1", "tslib": "2.3.1" @@ -6281,6 +6282,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dfde9d6..657336e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@fluentui/react": "^8.106.4", - "@pnp/sp": "^4.20.0", "@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-http": "1.22.1", @@ -21,6 +20,8 @@ "@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-property-pane": "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-dom": "17.0.1", "tslib": "2.3.1" diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.module.scss b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.module.scss index 827d923..f6d4810 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.module.scss @@ -1,23 +1,63 @@ @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 { - background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%); + display: flex; + align-items: stretch; 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; } +.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 { padding: 28px 30px 36px; max-width: 1080px; 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 { padding: 60px 30px; text-align: center; diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 9537c3a..fe98a59 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; 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 { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; @@ -8,6 +10,7 @@ import { SharePointListsClient } from '../../services/sharePointLists'; import { RequestManageService } from '../../services/requestManageService'; import { RequestType, TemplateRoute } from '../../models'; import { navigate, useRoute } from '../../routing'; +import { texts } from '../../texts'; import { DashboardScreen } from '../screens/DashboardScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RoutesScreen } from '../screens/RoutesScreen'; @@ -15,6 +18,22 @@ import { RouteStepsScreen } from '../screens/RouteStepsScreen'; import { ResolveScreen } from '../screens/ResolveScreen'; 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 { api: ApprovalRouteApi; /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ @@ -28,8 +47,13 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint const route = useRoute(); const [selectedRoute, setSelectedRoute] = React.useState(); const [selectedRequestType, setSelectedRequestType] = React.useState(); + 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 => { setSelectedRoute(r); @@ -76,41 +100,58 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint return (
- -
- {route.tab === 'dashboard' && } - {route.tab === 'requestTypes' && } - {route.tab === 'routes' && !route.routeId && ( - - )} - {route.tab === 'routes' && route.routeId && selectedRoute && ( - navigate({ tab: 'routes', routesFilter: route.routesFilter })} - /> - )} - {route.tab === 'resolve' && ( - - )} - {route.tab === 'manageRequests' && ( - - )} + +
+ navigateTab(id as ScreenKey)} + brand={texts.nav.title} + collapsed={navCollapsed} + onToggleCollapsed={() => setNavCollapsed(v => !v)} + mobileOpen={mobileNavOpen} + onMobileClose={() => setMobileNavOpen(false)} + />
+
+
+ setMobileNavOpen(true)} /> +
+
+ {route.tab === 'dashboard' && } + {route.tab === 'requestTypes' && } + {route.tab === 'routes' && !route.routeId && ( + + )} + {route.tab === 'routes' && route.routeId && selectedRoute && ( + navigate({ tab: 'routes', routesFilter: route.routesFilter })} + /> + )} + {route.tab === 'resolve' && ( + + )} + {route.tab === 'manageRequests' && ( + + )} +
+
); }; diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss b/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss deleted file mode 100644 index b5bf541..0000000 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss +++ /dev/null @@ -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; } -} diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx b/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx deleted file mode 100644 index 2a33140..0000000 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx +++ /dev/null @@ -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 = ({ active, onSelect }) => { - const tabsRef = React.useRef(null); - const activeTabRef = React.useRef(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 ( - - ); -};