diff --git a/ordinis-admin-ui/src/components/layout/Sidebar.tsx b/ordinis-admin-ui/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..b20f361 --- /dev/null +++ b/ordinis-admin-ui/src/components/layout/Sidebar.tsx @@ -0,0 +1,204 @@ +import { Link, useLocation } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { + Home, + Database, + FileText, + ClipboardList, + Inbox, + Webhook, + ScrollText, + Search, + GitBranch, + type LucideIcon, +} from 'lucide-react' +import { useAuth } from 'react-oidc-context' +import { useCanMutate } from '@/auth/useCanMutate' +import { useDictionaries, useMyDrafts } from '@/api/queries' +import { cn } from '@/lib/utils' + +/** + * Sidebar — 220px nav rail per handoff design system. + * + * Structure (top-to-bottom): + * - Logo block (ORDINIS wordmark в Tektur) + * - Section: navigation rail (Home, Справочники [count], Поиск, Граф) + * - Section: admin-only (Мои черновики, На ревью [count], Аудит, Outbox, Webhooks) + * - User profile (bottom, sticky) + * + * Responsive: на <1024px collapse в icon-only rail (56px). Smaller — overlay + * drawer (TODO Stage 3.x). Сейчас always-visible — main grid сам адаптирует. + */ + +type NavSection = { + /** Optional .cap header above items. */ + label?: string + items: NavItem[] +} + +type NavItem = { + to: string + label: string + icon: LucideIcon + /** Badge content справа (число pending / counts). */ + badge?: React.ReactNode + /** Только аутентифицированным пользователям. */ + authRequired?: boolean +} + +export function Sidebar() { + const { t } = useTranslation() + const auth = useAuth() + const canMutate = useCanMutate() + const dictsQuery = useDictionaries() + // Loaded just for сидбар counter — light request, кэшируется. + const myDrafts = useMyDrafts(0, 1) + + const dictsCount = dictsQuery.data?.length ?? 0 + const myDraftsCount = myDrafts.data?.totalElements ?? 0 + // Reviews count бэйдж — TODO когда будет cheap endpoint /drafts/pending/count. + // Сейчас GET /drafts/pending возвращает page, paged запрос на каждый mount + // sidebar'а — overkill. Skip badge. + const reviewsCount = 0 + + const sections: NavSection[] = [ + { + items: [ + { to: '/', label: t('nav.home'), icon: Home }, + { + to: '/dictionaries', + label: t('nav.dictionaries'), + icon: Database, + badge: dictsCount > 0 ? dictsCount : undefined, + }, + { to: '/search', label: t('nav.search'), icon: Search }, + // Graph view — Stage 3.3 placeholder. Когда d3-force готов. + { to: '/graph', label: t('nav.graph'), icon: GitBranch }, + ], + }, + { + label: t('nav.section.workflow'), + items: [ + { + to: '/my-drafts', + label: t('nav.myDrafts'), + icon: FileText, + badge: myDraftsCount > 0 ? myDraftsCount : undefined, + authRequired: true, + }, + { + to: '/reviews', + label: t('nav.reviews'), + icon: ClipboardList, + badge: reviewsCount > 0 ? reviewsCount : undefined, + authRequired: true, + }, + ], + }, + { + label: t('nav.section.admin'), + items: [ + { to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true }, + { to: '/outbox', label: t('nav.outbox'), icon: Inbox, authRequired: true }, + { to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, authRequired: true }, + ], + }, + ] + + const visibleSections = sections + .map((s) => ({ + ...s, + items: s.items.filter((i) => !i.authRequired || canMutate), + })) + .filter((s) => s.items.length > 0) + + return ( + + ) +} + +function SidebarLink({ to, label, icon: Icon, badge }: NavItem) { + const location = useLocation() + const active = + to === '/' + ? location.pathname === '/' + : location.pathname === to || location.pathname.startsWith(to + '/') + + return ( + + + {label} + {badge !== undefined && ( + + {badge} + + )} + + ) +} diff --git a/ordinis-admin-ui/src/components/layout/TopBar.tsx b/ordinis-admin-ui/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..065cfa6 --- /dev/null +++ b/ordinis-admin-ui/src/components/layout/TopBar.tsx @@ -0,0 +1,139 @@ +import { useTranslation } from 'react-i18next' +import { Link, useLocation, useNavigate } from '@tanstack/react-router' +import { LanguageSwitch } from '@/ui' +import { AuthBadge } from '@/auth/AuthBadge' +import { ThemeSwitch } from '@/components/layout/ThemeSwitch' +import { VersionBadge } from '@/components/version/VersionBadge' +import { SearchInput } from '@/ui/components/search-input' +import { useState } from 'react' + +/** + * TopBar — sticky header (h=56) per handoff design. + * + * Structure: + * - Left: dynamic breadcrumb based on current route + * - Center: global SearchInput → submits → navigates to /search?q= + * - Right: VersionBadge · ThemeSwitch · LanguageSwitch · Docs · AuthBadge + * + * Mobile (lg:): на узких экранах sidebar скрывается, breadcrumb sustaiable. + */ +const LANG_OPTIONS = [ + { id: 'ru-RU', label: 'RU' }, + { id: 'en-US', label: 'EN' }, +] + +export function TopBar() { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + const [searchValue, setSearchValue] = useState('') + + const breadcrumb = useBreadcrumb() + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault() + const q = searchValue.trim() + if (q.length >= 3) { + navigate({ to: '/search', search: { q } }) + } + } + + return ( +
+ {/* Breadcrumb — left */} + + + {/* Search — center, max-width per handoff */} +
+ setSearchValue(e.target.value)} + onClear={() => setSearchValue('')} + aria-label={t('topbar.search.label')} + /> + + + {/* Right cluster */} +
+ + {t('nav.docs')} + + + + i18n.changeLanguage(id)} + /> + +
+
+ ) +} + +/** + * Breadcrumb из current route — простой mapping для типичных страниц. + * Сложные breadcrumbs (dict name, record bk) — фактически TopBar shows + * generic, а более детальный crumb рендерит сам route через PageHeader. + */ +function useBreadcrumb(): { label: string; to?: string }[] { + const { t } = useTranslation() + const location = useLocation() + const path = location.pathname + + if (path === '/' || path === '') { + return [{ label: t('nav.home') }] + } + + const segments = path.split('/').filter(Boolean) + const first = segments[0] + + const ROOT_LABELS: Record = { + dictionaries: t('nav.dictionaries'), + search: t('nav.search'), + graph: t('nav.graph'), + 'my-drafts': t('nav.myDrafts'), + reviews: t('nav.reviews'), + audit: t('nav.audit'), + outbox: t('nav.outbox'), + webhooks: t('nav.webhooks'), + } + + const rootLabel = ROOT_LABELS[first] ?? first + const crumbs: { label: string; to?: string }[] = [ + { label: rootLabel, to: segments.length === 1 ? undefined : `/${first}` }, + ] + + if (segments.length >= 2) { + // Второй сегмент — id / name. Не маппим, оставляем raw. + crumbs.push({ label: decodeURIComponent(segments[1]) }) + } + + return crumbs +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index edc48c8..b360e94 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -18,6 +18,16 @@ i18n 'theme.dark': 'Тёмная', 'theme.system': 'Системная', 'nav.docs': 'Руководство', + 'nav.home': 'Главная', + 'nav.graph': 'Граф связей', + 'nav.section.workflow': 'Workflow', + 'nav.section.admin': 'Администрирование', + 'topbar.search.label': 'Поиск', + 'topbar.search.placeholder': 'Поиск по справочникам, ⌘K', + 'graph.title': 'Граф связей справочников', + 'graph.description': 'Сетевая диаграмма всех справочников и их FK-связей. Кликните по узлу — откроется детальный вид.', + 'graph.comingSoon.title': 'Скоро', + 'graph.comingSoon.description': 'Visualization через d3-force — 40+ справочников с linked edges. В разработке (Stage 3.3 redesign roadmap).', 'updateBanner.message': 'Доступна новая версия НСИ ({{version}}). Обновите страницу, чтобы получить последние исправления.', 'updateBanner.reload': 'Обновить', 'updateBanner.docs': 'Что нового', @@ -523,6 +533,16 @@ i18n 'theme.dark': 'Dark', 'theme.system': 'System', 'nav.docs': 'Docs', + 'nav.home': 'Home', + 'nav.graph': 'Relations graph', + 'nav.section.workflow': 'Workflow', + 'nav.section.admin': 'Admin', + 'topbar.search.label': 'Search', + 'topbar.search.placeholder': 'Search dictionaries, ⌘K', + 'graph.title': 'Dictionary relations graph', + 'graph.description': 'Network diagram of all dictionaries and their FK links. Click a node for detail.', + 'graph.comingSoon.title': 'Coming soon', + 'graph.comingSoon.description': 'd3-force visualization of 40+ dictionaries with linked edges. WIP (Stage 3.3 redesign roadmap).', 'updateBanner.message': 'A new NSI version ({{version}}) is available. Reload to get the latest fixes.', 'updateBanner.reload': 'Reload', 'updateBanner.docs': "What's new", diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts index 21d8564..8e6c708 100644 --- a/ordinis-admin-ui/src/routeTree.gen.ts +++ b/ordinis-admin-ui/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as SearchRouteImport } from './routes/search' import { Route as ReviewsRouteImport } from './routes/reviews' import { Route as OutboxRouteImport } from './routes/outbox' import { Route as MyDraftsRouteImport } from './routes/my-drafts' +import { Route as GraphRouteImport } from './routes/graph' import { Route as DictionariesRouteImport } from './routes/dictionaries' import { Route as AuditRouteImport } from './routes/audit' import { Route as IndexRouteImport } from './routes/index' @@ -47,6 +48,11 @@ const MyDraftsRoute = MyDraftsRouteImport.update({ path: '/my-drafts', getParentRoute: () => rootRouteImport, } as any) +const GraphRoute = GraphRouteImport.update({ + id: '/graph', + path: '/graph', + getParentRoute: () => rootRouteImport, +} as any) const DictionariesRoute = DictionariesRouteImport.update({ id: '/dictionaries', path: '/dictionaries', @@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren + '/graph': typeof GraphRoute '/my-drafts': typeof MyDraftsRoute '/outbox': typeof OutboxRoute '/reviews': typeof ReviewsRoute @@ -100,6 +107,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/audit': typeof AuditRoute + '/graph': typeof GraphRoute '/my-drafts': typeof MyDraftsRoute '/outbox': typeof OutboxRoute '/reviews': typeof ReviewsRoute @@ -114,6 +122,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren + '/graph': typeof GraphRoute '/my-drafts': typeof MyDraftsRoute '/outbox': typeof OutboxRoute '/reviews': typeof ReviewsRoute @@ -130,6 +139,7 @@ export interface FileRouteTypes { | '/' | '/audit' | '/dictionaries' + | '/graph' | '/my-drafts' | '/outbox' | '/reviews' @@ -143,6 +153,7 @@ export interface FileRouteTypes { to: | '/' | '/audit' + | '/graph' | '/my-drafts' | '/outbox' | '/reviews' @@ -156,6 +167,7 @@ export interface FileRouteTypes { | '/' | '/audit' | '/dictionaries' + | '/graph' | '/my-drafts' | '/outbox' | '/reviews' @@ -171,6 +183,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuditRoute: typeof AuditRoute DictionariesRoute: typeof DictionariesRouteWithChildren + GraphRoute: typeof GraphRoute MyDraftsRoute: typeof MyDraftsRoute OutboxRoute: typeof OutboxRoute ReviewsRoute: typeof ReviewsRoute @@ -215,6 +228,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MyDraftsRouteImport parentRoute: typeof rootRouteImport } + '/graph': { + id: '/graph' + path: '/graph' + fullPath: '/graph' + preLoaderRoute: typeof GraphRouteImport + parentRoute: typeof rootRouteImport + } '/dictionaries': { id: '/dictionaries' path: '/dictionaries' @@ -299,6 +319,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuditRoute: AuditRoute, DictionariesRoute: DictionariesRouteWithChildren, + GraphRoute: GraphRoute, MyDraftsRoute: MyDraftsRoute, OutboxRoute: OutboxRoute, ReviewsRoute: ReviewsRoute, diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index 6937da9..4b9c706 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -1,118 +1,40 @@ -import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router' +import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' import type { QueryClient } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' -import { LanguageSwitch } from '@/ui' -import { AuthBadge } from '@/auth/AuthBadge' -import { useCanMutate } from '@/auth/useCanMutate' -import { ThemeSwitch } from '@/components/layout/ThemeSwitch' +import { Sidebar } from '@/components/layout/Sidebar' +import { TopBar } from '@/components/layout/TopBar' import { UpdateBanner } from '@/components/version/UpdateBanner' -import { VersionBadge } from '@/components/version/VersionBadge' +/** + * Root layout (Stage 3.1 design handoff): + * + * ┌─ Sidebar 220px (lg:flex) ──┬─ main column (flex-1) ────────────┐ + * │ logo │ UpdateBanner (when applicable) │ + * │ nav rail с counters │ TopBar h=56 sticky │ + * │ user profile │ Outlet (route content) │ + * └────────────────────────────┴───────────────────────────────────┘ + * + * Responsive: на <1024px sidebar скрывается (hidden lg:flex). Main grid + * растягивается на весь viewport. Mobile hamburger drawer — TODO Stage 3.x. + */ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ component: RootLayout, }) -const LANG_OPTIONS = [ - { id: 'ru-RU', label: 'RU' }, - { id: 'en-US', label: 'EN' }, -] - function RootLayout() { - const { t, i18n } = useTranslation() - // Гость (anonymous) видит только ссылку на /dictionaries и /search. - // Аудит, outbox, webhooks, drafts, reviews — admin-only страницы, требуют - // JWT (backend SecurityConfig + admin role). Скрываем из top-nav чтобы - // user не получал 401 toast при навигации. - const canMutate = useCanMutate() return ( -
+
-
-
- - {t('app.title')} - - -
- - - i18n.changeLanguage(id)} - /> - -
+
+ +
+ +
+
+ +
+
-
-
- -
+
) } diff --git a/ordinis-admin-ui/src/routes/graph.tsx b/ordinis-admin-ui/src/routes/graph.tsx new file mode 100644 index 0000000..779e03e --- /dev/null +++ b/ordinis-admin-ui/src/routes/graph.tsx @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { GitBranch } from 'lucide-react' +import { PageHeader } from '@/ui' +import { EmptyState } from '@/ui/components/empty-state' + +/** + * Граф связей справочников — d3-force network diagram. + * + * Stage 3.3 placeholder. Compute graph from {@code useDictionaries() + + * useQueries(dependents)}: nodes = dicts, edges = FK refs. Layout — + * d3-force с link/charge/center forces. Hover на node → highlight + * incoming + outgoing. Click → navigate to dict. + * + * Dependency: `d3-force` (без full d3, ~30kb gz). Render через canvas + * для 40+ nodes performance. + */ +export const Route = createFileRoute('/graph')({ + component: GraphPage, +}) + +function GraphPage() { + const { t } = useTranslation() + return ( +
+ + } + title={t('graph.comingSoon.title')} + description={t('graph.comingSoon.description')} + /> +
+ ) +}