import { useState } from 'react' import { Link, useLocation } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Database, FileText, ClipboardList, Inbox, Webhook, ScrollText, Search, type LucideIcon, } from 'lucide-react' import { useAuth } from '@/auth/useAuth' import { useCanMutate } from '@/auth/useCanMutate' import { useCanReviewRecord, useCanReviewSchema, useHasInternalScope, } from '@/auth/usePermissions' import { useMyDrafts, useMySchemaDrafts, useOutboxStats, useReviewsBadgeCount, useWebhookSubscriptions, } from '@/api/queries' import { Sheet, SheetContent, SheetTitle } from '@/ui' import { ThemeSwitch } from '@/components/layout/ThemeSwitch' import { classifyRecordStatus, classifySchemaStatus } from '@/components/reviews/MyDraftsPanel' 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 /** * Visible только если user имеет хотя бы одну из workflow ролей * (или {@code ordinis:admin} super-role). Hide если нет. */ reviewerOnly?: boolean /** * Admin-only nav entries (outbox/audit/webhooks). Backend возвращает 403 * audit_internal_required / outbox_internal_required для PUBLIC-scope users. * Frontend hide'ит вместо показа links → 403 toast (confusing UX). * Hide когда {@link useHasInternalScope} returns false. */ internalOnly?: boolean } export function Sidebar() { const { t } = useTranslation() const auth = useAuth() const canMutate = useCanMutate() // Phase 1d role-based sidebar visibility. /reviews пункт показываем // только тем у кого есть schema:reviewer OR record:reviewer OR // ordinis:admin super-role. Иначе пункт скрыт — у user'а нет смысла // его видеть (clicking всё равно даст empty queue / 403). const canReviewSchema = useCanReviewSchema() const canReviewRecord = useCanReviewRecord() const isReviewer = canReviewSchema || canReviewRecord // Loaded just for сидбар counter — light request, кэшируется. // Badge "Мои черновики" суммирует record (/drafts/me) + schema // (/admin/schema-drafts/me) — maker не должен помнить разные типы. // // page=0 size=50 (не size=1!) — backend /drafts/me возвращает ВСЕ статусы // (PENDING + APPROVED + REJECTED + WITHDRAWN), а totalElements не умеет // filter по статусу. Нужно фильтровать на клиенте: считаем только // pending/WIP-драфты, чтобы approved/rejected не «висели» в badge. // Reuse: на /reviews этот же query (size=50) уже зовётся → TanStack // дедуплицирует по queryKey, лишних запросов нет. const myDrafts = useMyDrafts(0, 50) const mySchemaDrafts = useMySchemaDrafts(0, 50) // Reviews badge — суммирует record-reviews + schema-reviews pending queues // через size=1 paged запросы. Gated на authenticated. const reviews = useReviewsBadgeCount(auth.isAuthenticated) // Outbox/Webhooks badges — light periodic poll. Backend гейтит INTERNAL // scope: пользователь без нужной роли получает 403 на каждый poll. Гейтим // на frontend через useHasInternalScope чтобы не спамить console 403'ами // на каждой странице. Для PUBLIC users badge остаётся пустым (не false-zero). const hasInternal = useHasInternalScope() const outboxStats = useOutboxStats(hasInternal) const webhooks = useWebhookSubscriptions(hasInternal) // Считаем только actionable-драфты (то же что tab-badge в ReviewsPage): // record → 'pending', schema → 'pending'|'wip'. APPROVED/REJECTED/WITHDRAWN // — терминальны, не считаем. const myRecordPending = (myDrafts.data?.items ?? []).filter( (r) => classifyRecordStatus(r.status) === 'pending', ).length const mySchemaPending = (mySchemaDrafts.data?.items ?? []).filter((s) => { const b = classifySchemaStatus(s.status) return b === 'pending' || b === 'wip' }).length const myDraftsCount = myRecordPending + mySchemaPending const reviewsCount = reviews.total // Pending events ждут dispatch. dlq отдельно — alarming, но не блокирует // нормальную работу, можно увидеть на /outbox. Badge показывает только // pending (actionable: 'надо разобраться почему queue растёт'). const outboxPending = outboxStats.data?.pending ?? 0 const webhooksActive = webhooks.data?.filter((w) => w.active).length ?? 0 // Flat list per redesign/compact.html — no section labels, /graph removed // (now reachable via catalog toolbar "Граф ⇄" button). QA bug #8: «Главная» // удалён — он вёл на `/` который сразу redirect'нет на /dictionaries; в // сайдбаре подсветка слетала (active state на /dictionaries, не на /). // Logo сверху уже даёт home affordance — duplicate nav item только сбивал. // Order: Справочники → Мои черновики → На ревью → Outbox → Webhooks → Аудит → Поиск. const sections: NavSection[] = [ { items: [ { to: '/dictionaries', label: t('nav.dictionaries'), icon: Database, // Без badge: total count словарей — не actionable, только шумит // (на проде ~40 dicts, цифра не меняется в течение работы юзера). // Actionable badges остаются: «Мои черновики», «На ревью», «Outbox». }, { 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, reviewerOnly: true, }, { to: '/outbox', label: t('nav.outbox'), icon: Inbox, badge: outboxPending > 0 ? outboxPending : undefined, authRequired: true, internalOnly: true, }, { to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, badge: webhooksActive > 0 ? webhooksActive : undefined, authRequired: true, internalOnly: true, }, // /audit, /outbox, /webhooks — admin observability tools. Backend // возвращает 403 (audit_internal_required / outbox_internal_required) // для PUBLIC-scope users. UI больше не показывает links — раньше // PUBLIC users видели nav entries и кликали → 403 toast (confusing). { to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true, internalOnly: true }, { to: '/search', label: t('nav.search'), icon: Search }, ], }, ] const visibleSections = sections .map((s) => ({ ...s, items: s.items.filter((i) => { if (i.authRequired && !canMutate) return false if (i.reviewerOnly && !isReviewer) return false if (i.internalOnly && !hasInternal) return false return true }), })) .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 }) { return ( <> {/* Logo block per redesign prototype: circle (accent) + ORDINIS + MDM mute */}
{/* Ordinis mark — orbital structure recolored под Ordinis palette. При мелком размере (28px) тонкие линии/маленькие dots становились почти невидимыми. Bumped strokeWidth до 5 + dot radii до 14/11 (внутри 256 viewBox = читаемые ~1.5px / 1.2px при 28px render). Theme-aware через currentColor + var(--color-line). */} {/* Outer orbits — line tone. Math: viewBox 256 при render 28px = * 0.11px/unit. stroke=12 даёт ~1.3px real — чёткая видимая линия. */} {/* Outer orbit dots (3) — large accent dots */} {/* Inner orbit dots (3) */} {/* Core — solid accent, prominent */} {!collapsed && ( ORDINIS MDM )}
{/* Nav sections */} {/* Footer: Lang toggle + User profile (or Sign-in) + Collapse toggle. * Moved from TopBar per user feedback: "вход в сайдбар, ru/en в сайдбаре". */} ) } function SidebarFooter({ auth, collapsed, onToggleCollapsed, }: { auth: ReturnType collapsed: boolean onToggleCollapsed?: () => void }) { const { t, i18n } = useTranslation() const cur = i18n.language?.toLowerCase().startsWith('en') ? 'en' : 'ru' const next = cur === 'ru' ? 'en-US' : 'ru-RU' return ( <> {/* Language toggle + Theme switch row (только когда expanded). * ThemeSwitch и LanguageSwitch переехали из TopBar per user * ("свитч темы можно тоже в профиль перенести"). */} {!collapsed && (
)} {/* Auth block: avatar + name (authed) OR Sign-in CTA (anonymous). */} {auth.isAuthenticated && auth.user?.profile ? ( ) : ( )} {/* Collapse toggle */} {onToggleCollapsed && ( )} ) } function AuthedUserBlock({ auth, collapsed, }: { auth: ReturnType collapsed: boolean }) { const { t } = useTranslation() const profile = auth.user?.profile if (!profile) return null const display = String( profile.preferred_username || profile.name || profile.email || 'user', ) return (
{display.charAt(0).toUpperCase()}
{!collapsed && (
{display}
{profile.email && (
{String(profile.email)}
)}
)} {!collapsed && ( )}
) } function SignInCta({ auth, collapsed, }: { auth: ReturnType collapsed: boolean }) { const { t } = useTranslation() return ( ) } /** * 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 canReviewSchema = useCanReviewSchema() const canReviewRecord = useCanReviewRecord() const isReviewer = canReviewSchema || canReviewRecord // size=50 (не size=1): см. desktop Sidebar — нужен .items[] для filter // по статусу (totalElements включает APPROVED/REJECTED). const myDrafts = useMyDrafts(0, 50) const mySchemaDrafts = useMySchemaDrafts(0, 50) const reviews = useReviewsBadgeCount(auth.isAuthenticated) // Same INTERNAL-scope gate as desktop Sidebar (см. line ~92). const hasInternal = useHasInternalScope() const outboxStats = useOutboxStats(hasInternal) const webhooks = useWebhookSubscriptions(hasInternal) // Считаем только actionable-драфты (то же что tab-badge в ReviewsPage): // record → 'pending', schema → 'pending'|'wip'. APPROVED/REJECTED/WITHDRAWN // — терминальны, не считаем. const myRecordPending = (myDrafts.data?.items ?? []).filter( (r) => classifyRecordStatus(r.status) === 'pending', ).length const mySchemaPending = (mySchemaDrafts.data?.items ?? []).filter((s) => { const b = classifySchemaStatus(s.status) return b === 'pending' || b === 'wip' }).length const myDraftsCount = myRecordPending + mySchemaPending const reviewsCount = reviews.total const outboxPending = outboxStats.data?.pending ?? 0 const webhooksActive = webhooks.data?.filter((w) => w.active).length ?? 0 // Flat list per redesign — same as desktop Sidebar above. QA bug #8: // «Главная» удалён (redirect → /dictionaries сбивал active state). const sections: NavSection[] = [ { items: [ { to: '/dictionaries', label: t('nav.dictionaries'), icon: Database, // Без badge: total count словарей — не actionable, только шумит // (на проде ~40 dicts, цифра не меняется в течение работы юзера). // Actionable badges остаются: «Мои черновики», «На ревью», «Outbox». }, { 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, reviewerOnly: true, }, { to: '/outbox', label: t('nav.outbox'), icon: Inbox, badge: outboxPending > 0 ? outboxPending : undefined, authRequired: true, internalOnly: true, }, { to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, badge: webhooksActive > 0 ? webhooksActive : undefined, authRequired: true, internalOnly: true, }, { to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true, internalOnly: true }, { to: '/search', label: t('nav.search'), icon: Search }, ], }, ] const visibleSections = sections .map((s) => ({ ...s, items: s.items.filter((i) => { if (i.authRequired && !canMutate) return false if (i.reviewerOnly && !isReviewer) return false if (i.internalOnly && !hasInternal) return false return true }), })) .filter((s) => s.items.length > 0) return ( { if (!v) onClose() }}> {/* Hidden title — Radix DialogContent (which Sheet wraps) требует DialogTitle для screen reader а11y. Визуально не нужен (logo block внутри SidebarContent уже служит heading), поэтому sr-only. Без этого Radix кидает dev-mode warning + screen reader users не понимают что за dialog открыт. */} {t('nav.menu', { defaultValue: 'Меню навигации' })} ) } 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 ( {/* Icon + corner-badge overlay в collapsed-mode. Wrapper relative нужен для absolute badge'а; в expanded-mode иконка без обёртки + badge справа от label'а как было. */} {collapsed ? ( {badge !== undefined && ( )} ) : ( <> {label} {badge !== undefined && ( {badge} )} )} ) }