import { useState } from 'react' 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 { Sheet, SheetContent } from '@/ui' 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) // Sidebar collapsed mode — per handoff prototype 'Свернуть' button bottom-left. // Persists в localStorage. Collapsed = icon-only 56px rail, expanded = 220px. const [collapsed, setCollapsed] = useState(() => { if (typeof window === 'undefined') return false return window.localStorage.getItem('ord-sidebar-collapsed') === '1' }) const toggleCollapsed = () => { setCollapsed((prev) => { const next = !prev try { window.localStorage.setItem('ord-sidebar-collapsed', next ? '1' : '0') } catch { // localStorage может быть disabled } return next }) } return ( ) } /** * SidebarContent — внутренности (logo + nav + user). Используется в desktop * rail (Sidebar) И в mobile drawer (MobileSidebar) — DRY. */ function SidebarContent({ sections, auth, onNavigate, collapsed = false, onToggleCollapsed, }: { sections: NavSection[] auth: ReturnType /** Called после клика на nav item — для close drawer в mobile mode. */ onNavigate?: () => void /** Desktop collapse state — affects labels visibility и width. */ collapsed?: boolean /** Toggle button handler. When undefined — collapse button hidden (mobile). */ onToggleCollapsed?: () => void }) { const { t } = useTranslation() return ( <> {/* Logo block per redesign prototype: circle (accent) + ORDINIS + MDM mute */}
{/* Brand orbit icon per redesign/ui-kit.html .brand SVG: outer ring r=13 + inner faded ring r=6 + orbit dot offset right cx=22.5. */} {!collapsed && ( ORDINIS MDM )}
{/* Nav sections */} {/* User block — sticky bottom */} {auth.isAuthenticated && auth.user?.profile && (
{String( auth.user.profile.preferred_username || auth.user.profile.name || 'U', ) .charAt(0) .toUpperCase()}
{!collapsed && (
{String( auth.user.profile.preferred_username || auth.user.profile.name || auth.user.profile.email || 'user', )}
{auth.user.profile.email && (
{String(auth.user.profile.email)}
)}
)}
)} {/* Collapse toggle — desktop only (mobile uses Sheet close ×) */} {onToggleCollapsed && ( )} ) } /** * MobileSidebar — Sheet с тем же содержимым что desktop Sidebar. Открывается * через hamburger button в TopBar (<1024px), закрывается на: backdrop click, * Escape, или клик на любой nav item (auto-close, привычная mobile UX). * *

Hooks logic дублируется (auth, dictsCount etc.) — нужно для filter * auth-required sections. Можно вынести в useSidebarSections() hook * следующим refactor. */ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () => void }) { const { t } = useTranslation() const auth = useAuth() const canMutate = useCanMutate() const dictsQuery = useDictionaries() const myDrafts = useMyDrafts(0, 1) const dictsCount = dictsQuery.data?.length ?? 0 const myDraftsCount = myDrafts.data?.totalElements ?? 0 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 }, { 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 ( { if (!v) onClose() }}> ) } function SidebarLink({ to, label, icon: Icon, badge, onClick, collapsed = false, }: NavItem & { onClick?: () => void; collapsed?: boolean }) { const location = useLocation() const active = to === '/' ? location.pathname === '/' : location.pathname === to || location.pathname.startsWith(to + '/') return ( {!collapsed && ( <> {label} {badge !== undefined && ( {badge} )} )} ) }